diff --git a/go.mod b/go.mod
index 69ee792..7a44a62 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,10 @@ go 1.25.7
require github.com/git-pkgs/purl v0.1.8
+require gopkg.in/yaml.v3 v3.0.1 // indirect
+
require (
+ github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc
github.com/git-pkgs/packageurl-go v0.2.1 // indirect
github.com/git-pkgs/vers v0.2.2 // indirect
)
diff --git a/go.sum b/go.sum
index d68576a..8460519 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,12 @@
+github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc h1:ps+yFaDqHvvKzgmFCfAJqN5agU4fXiAMsYOJNG6pt3s=
+github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc/go.mod h1:8DR7tIQEEyPyJ7QGzStVbovnGl7tAJcrhQLzjQPzFzc=
github.com/git-pkgs/packageurl-go v0.2.1 h1:j6VnjJiYS9b1nTLfJGsG6SLaA7Nk6Io+ta8grOyTa4o=
github.com/git-pkgs/packageurl-go v0.2.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
github.com/git-pkgs/purl v0.1.8 h1:iyjEHM2WIZUL9A3+q9ylrabqILsN4nOay9X6jfEjmzQ=
github.com/git-pkgs/purl v0.1.8/go.mod h1:ihlHw3bnSLXat+9Nl9MsJZBYiG7s3NkwmvE3L/Es/sI=
github.com/git-pkgs/vers v0.2.2 h1:42QkiIURhGN2wM8AuYYU+FbzS1YV6jmdGd1RiFp7gXs=
github.com/git-pkgs/vers v0.2.2/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/resolve_deps.go b/resolve_deps.go
new file mode 100644
index 0000000..a9a4742
--- /dev/null
+++ b/resolve_deps.go
@@ -0,0 +1,262 @@
+package resolve
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/git-pkgs/managers"
+ "github.com/git-pkgs/managers/definitions"
+)
+
+// InputDep describes a dependency to add to the generated project.
+type InputDep struct {
+ Name string // package name in ecosystem-native format
+ Version string // version constraint (optional)
+}
+
+// ErrResolveNotSupported is returned when the manager does not support the resolve operation.
+var ErrResolveNotSupported = errors.New("manager does not support resolve")
+
+// Managers returns the list of manager names that have registered parsers.
+func Managers() []string {
+ names := make([]string, 0, len(parsers))
+ for name := range parsers {
+ names = append(names, name)
+ }
+ return names
+}
+
+// EcosystemForManager returns the ecosystem name for a registered manager.
+func EcosystemForManager(manager string) (string, bool) {
+ eco, ok := managerEcosystem[manager]
+ return eco, ok
+}
+
+// ResolveDeps creates a temporary project, adds the given dependencies using the
+// specified package manager, runs resolution, and parses the output into a
+// dependency graph.
+//
+// The package manager CLI must be installed and available on PATH.
+func ResolveDeps(ctx context.Context, manager string, deps []InputDep) (*Result, error) {
+ // Verify we have a parser for this manager.
+ if _, ok := parsers[manager]; !ok {
+ return nil, fmt.Errorf("%w: %s", ErrUnsupportedManager, manager)
+ }
+
+ // Clear environment variables that might leak from parent processes
+ // (e.g. BUNDLE_GEMFILE from a Rails app calling this binary).
+ clearParentEnv()
+
+ // Create temp directory for the project.
+ tmpDir, err := os.MkdirTemp("", "resolve-*")
+ if err != nil {
+ return nil, fmt.Errorf("creating temp dir: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Set up the managers library.
+ defs, err := definitions.LoadEmbedded()
+ if err != nil {
+ return nil, fmt.Errorf("loading manager definitions: %w", err)
+ }
+
+ translator := managers.NewTranslator()
+ runner := managers.NewExecRunner()
+ detector := managers.NewDetector(translator, runner)
+ for _, def := range defs {
+ detector.Register(def)
+ }
+
+ mgr, err := detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
+ if err != nil {
+ return nil, fmt.Errorf("setting up manager %s: %w", manager, err)
+ }
+
+ // Init the project and add dependencies.
+ if mgr.Supports(managers.CapInit) {
+ result, err := mgr.Init(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("init %s: %w", manager, err)
+ }
+ if !result.Success() {
+ return nil, fmt.Errorf("init %s: exit %d: %s", manager, result.ExitCode, result.Stderr)
+ }
+
+ // Re-detect after init (some commands create subdirectories).
+ mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
+ if err != nil {
+ return nil, fmt.Errorf("re-detecting manager after init: %w", err)
+ }
+
+ // Add each dependency via the manager CLI.
+ if mgr.Supports(managers.CapAdd) {
+ seen := make(map[string]bool)
+ for _, dep := range deps {
+ if seen[dep.Name] {
+ continue
+ }
+ seen[dep.Name] = true
+
+ result, err := mgr.Add(ctx, dep.Name, managers.AddOptions{Version: dep.Version})
+ if err != nil {
+ return nil, fmt.Errorf("add %s: %w", dep.Name, err)
+ }
+ if !result.Success() {
+ return nil, fmt.Errorf("add %s: exit %d: %s", dep.Name, result.ExitCode, result.Stderr)
+ }
+ }
+ }
+ } else {
+ // Fallback: write a minimal manifest for managers without init.
+ if err := writeManifest(tmpDir, manager, deps); err != nil {
+ return nil, fmt.Errorf("writing manifest for %s: %w", manager, err)
+ }
+
+ // Re-detect so the manager sees the manifest.
+ mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
+ if err != nil {
+ return nil, fmt.Errorf("detecting manager after manifest write: %w", err)
+ }
+
+ // Run install to resolve dependencies.
+ installResult, err := mgr.Install(ctx, managers.InstallOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("install %s: %w", manager, err)
+ }
+ if !installResult.Success() {
+ return nil, fmt.Errorf("install %s: exit %d: %s", manager, installResult.ExitCode, installResult.Stderr)
+ }
+ }
+
+ // Run resolve to get the dependency graph output.
+ if !mgr.Supports(managers.CapResolve) {
+ return nil, fmt.Errorf("%w: %s", ErrResolveNotSupported, manager)
+ }
+
+ resolveResult, err := mgr.Resolve(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("resolve %s: %w", manager, err)
+ }
+
+ // Parse the output.
+ return Parse(manager, []byte(resolveResult.Stdout))
+}
+
+// writeManifest creates a minimal manifest file for managers that don't support init.
+func writeManifest(dir, manager string, deps []InputDep) error {
+ switch manager {
+ case "pip":
+ return writePipManifest(dir, deps)
+ case "maven":
+ return writeMavenManifest(dir, deps)
+ case "sbt":
+ return writeSbtManifest(dir, deps)
+ default:
+ return fmt.Errorf("no manifest template for manager %s", manager)
+ }
+}
+
+func writePipManifest(dir string, deps []InputDep) error {
+ var lines []string
+ for _, dep := range deps {
+ if dep.Version != "" {
+ lines = append(lines, dep.Name+dep.Version)
+ } else {
+ lines = append(lines, dep.Name)
+ }
+ }
+ return os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0644)
+}
+
+func writeMavenManifest(dir string, deps []InputDep) error {
+ var depXML strings.Builder
+ for _, dep := range deps {
+ // Maven deps use groupId:artifactId format
+ parts := strings.SplitN(dep.Name, ":", 2)
+ groupID := parts[0]
+ artifactID := groupID
+ if len(parts) == 2 {
+ artifactID = parts[1]
+ }
+ version := dep.Version
+ if version == "" {
+ version = "[0,)"
+ }
+ depXML.WriteString(" \n")
+ depXML.WriteString(" " + groupID + "\n")
+ depXML.WriteString(" " + artifactID + "\n")
+ depXML.WriteString(" " + version + "\n")
+ depXML.WriteString(" \n")
+ }
+
+ pom := `
+
+ 4.0.0
+ resolve
+ resolve-tmp
+ 0.0.1
+
+` + depXML.String() + `
+
+`
+ return os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644)
+}
+
+func writeSbtManifest(dir string, deps []InputDep) error {
+ var lines []string
+ lines = append(lines, `name := "resolve-tmp"`)
+ lines = append(lines, `version := "0.0.1"`)
+ lines = append(lines, "")
+
+ var depLines []string
+ for _, dep := range deps {
+ parts := strings.SplitN(dep.Name, ":", 2)
+ groupID := parts[0]
+ artifactID := groupID
+ if len(parts) == 2 {
+ artifactID = parts[1]
+ }
+ version := dep.Version
+ if version == "" {
+ version = "LATEST"
+ }
+ depLines = append(depLines, fmt.Sprintf(` "%s" %% "%s" %% "%s"`, groupID, artifactID, version))
+ }
+ if len(depLines) > 0 {
+ lines = append(lines, "libraryDependencies ++= Seq(")
+ lines = append(lines, strings.Join(depLines, ",\n"))
+ lines = append(lines, ")")
+ }
+
+ return os.WriteFile(filepath.Join(dir, "build.sbt"), []byte(strings.Join(lines, "\n")+"\n"), 0644)
+}
+
+// envVarsToClear lists specific environment variables that point to a parent
+// project and would cause package manager commands to operate on the wrong
+// directory (e.g. BUNDLE_GEMFILE from a Rails app).
+var envVarsToClear = []string{
+ "BUNDLE_GEMFILE",
+ "BUNDLE_LOCKFILE",
+ "BUNDLE_BIN_PATH",
+ "BUNDLE_PATH",
+ "BUNDLER_SETUP",
+ "BUNDLER_VERSION",
+ "GEM_HOME",
+ "GEM_PATH",
+ "RUBYOPT",
+ "RUBYLIB",
+}
+
+// clearParentEnv removes environment variables that could interfere with
+// package manager commands run in a temporary directory.
+func clearParentEnv() {
+ for _, key := range envVarsToClear {
+ os.Unsetenv(key)
+ }
+}
diff --git a/resolve_deps_test.go b/resolve_deps_test.go
new file mode 100644
index 0000000..79117b3
--- /dev/null
+++ b/resolve_deps_test.go
@@ -0,0 +1,96 @@
+package resolve_test
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/git-pkgs/resolve"
+ _ "github.com/git-pkgs/resolve/parsers"
+)
+
+func hasCommand(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
+
+func TestResolveDepsUnsupportedManager(t *testing.T) {
+ ctx := context.Background()
+ _, err := resolve.ResolveDeps(ctx, "nonexistent", nil)
+ if !errors.Is(err, resolve.ErrUnsupportedManager) {
+ t.Errorf("expected ErrUnsupportedManager, got %v", err)
+ }
+}
+
+func TestResolveDepsNPM(t *testing.T) {
+ if !hasCommand("npm") {
+ t.Skip("npm not installed")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ result, err := resolve.ResolveDeps(ctx, "npm", []resolve.InputDep{
+ {Name: "express", Version: "4.21.2"},
+ })
+ if err != nil {
+ t.Fatalf("ResolveDeps failed: %v", err)
+ }
+
+ if result.Manager != "npm" {
+ t.Errorf("Manager = %q, want %q", result.Manager, "npm")
+ }
+ if result.Ecosystem != "npm" {
+ t.Errorf("Ecosystem = %q, want %q", result.Ecosystem, "npm")
+ }
+
+ express := findDep(result.Direct, "express")
+ if express == nil {
+ t.Fatal("missing express in results")
+ }
+ if express.Version != "4.21.2" {
+ t.Errorf("express version = %q, want %q", express.Version, "4.21.2")
+ }
+ // express has 30+ transitive deps
+ if len(express.Deps) < 10 {
+ t.Errorf("expected express to have 10+ transitive deps, got %d", len(express.Deps))
+ }
+
+ // spot check a known transitive dep
+ bodyParser := findDep(express.Deps, "body-parser")
+ if bodyParser == nil {
+ t.Fatal("missing body-parser as transitive dep of express")
+ }
+}
+
+func TestResolveDepsManagers(t *testing.T) {
+ names := resolve.Managers()
+ if len(names) == 0 {
+ t.Fatal("expected at least one registered manager")
+ }
+}
+
+func TestResolveDepsEcosystemForManager(t *testing.T) {
+ eco, ok := resolve.EcosystemForManager("npm")
+ if !ok {
+ t.Fatal("expected npm to be registered")
+ }
+ if eco != "npm" {
+ t.Errorf("ecosystem = %q, want %q", eco, "npm")
+ }
+
+ eco, ok = resolve.EcosystemForManager("bundler")
+ if !ok {
+ t.Fatal("expected bundler to be registered")
+ }
+ if eco != "gem" {
+ t.Errorf("ecosystem = %q, want %q", eco, "gem")
+ }
+
+ _, ok = resolve.EcosystemForManager("nonexistent")
+ if ok {
+ t.Error("expected nonexistent manager to not be registered")
+ }
+}