Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
262 changes: 262 additions & 0 deletions resolve_deps.go
Original file line number Diff line number Diff line change
@@ -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(" <dependency>\n")
depXML.WriteString(" <groupId>" + groupID + "</groupId>\n")
depXML.WriteString(" <artifactId>" + artifactID + "</artifactId>\n")
depXML.WriteString(" <version>" + version + "</version>\n")
depXML.WriteString(" </dependency>\n")
}

pom := `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>resolve</groupId>
<artifactId>resolve-tmp</artifactId>
<version>0.0.1</version>
<dependencies>
` + depXML.String() + ` </dependencies>
</project>
`
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)
}
}
96 changes: 96 additions & 0 deletions resolve_deps_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}