diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 6c75720..b5fe81d 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -30,6 +30,7 @@ import ( "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" + "banger/internal/toolingplan" "banger/internal/vmdns" "banger/internal/vsockagent" @@ -56,7 +57,8 @@ var ( opencodeCmd.Stdin = stdin return opencodeCmd.Run() } - hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { + hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported + hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.CombinedOutput() if err == nil { @@ -94,12 +96,14 @@ var ( guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) { return guest.Dial(ctx, address, privateKeyPath) } - prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy - cwdFunc = os.Getwd + prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy + buildVMRunToolingPlanFunc = toolingplan.Build + cwdFunc = os.Getwd ) type vmRunGuestClient interface { Close() error + UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error RunScript(ctx context.Context, script string, logWriter io.Writer) error StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error @@ -121,6 +125,22 @@ type vmRunRepoSpec struct { const vmRunShallowFetchDepth = 10 +const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free" +const vmRunToolingHarnessTimeoutSeconds = 45 +const vmRunToolingInstallTimeoutSeconds = 120 + +const vmRunToolingHarnessPrompt = `You are preparing a development VM for this repository. + +Inspect the repository for developer tools and binaries that are clearly needed to work on it. Look at files like .mise.toml, .tool-versions, README/setup docs, CI config, task runners, scripts, and build manifests. + +Rules: +- Use mise only for installs. +- Do not edit repository files. +- Prefer repo-declared versions first. +- If a tool is clearly required but not pinned, you may install a conservative guest-global tool with mise. +- Skip ambiguous installs instead of guessing. +- End with a short summary of what you installed and what you skipped.` + func NewBangerCommand() *cobra.Command { root := &cobra.Command{ Use: "banger", @@ -1613,8 +1633,10 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil { return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err) } - progress.render("attaching opencode") - if err := runVMRunAttach(ctx, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName)); err != nil { + if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil { + printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err)) + } + if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName), progress); err != nil { return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err) } return nil @@ -1736,16 +1758,196 @@ func vmRunGuestDir(repoName string) string { return filepath.ToSlash(filepath.Join("/root", repoName)) } -func runVMRunAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string) error { +func vmRunToolingHarnessPath(repoName string) string { + return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh")) +} + +func vmRunToolingHarnessPromptPath(repoName string) string { + return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".prompt.txt")) +} + +func vmRunToolingHarnessLogPath(repoName string) string { + return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log")) +} + +func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error { + if progress != nil { + progress.render("starting tooling harness") + } + plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot) + var uploadLog bytes.Buffer + if err := client.UploadFile(ctx, vmRunToolingHarnessPromptPath(spec.RepoName), 0o644, []byte(vmRunToolingHarnessPromptData(plan)), &uploadLog); err != nil { + return formatVMRunStepError("upload tooling harness prompt", err, uploadLog.String()) + } + uploadLog.Reset() + if err := client.UploadFile(ctx, vmRunToolingHarnessPath(spec.RepoName), 0o755, []byte(vmRunToolingHarnessScript(spec, plan)), &uploadLog); err != nil { + return formatVMRunStepError("upload tooling harness", err, uploadLog.String()) + } + var launchLog bytes.Buffer + if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil { + return formatVMRunStepError("launch tooling harness", err, launchLog.String()) + } + if progress != nil { + progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName)) + } + return nil +} + +func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string { + var prompt strings.Builder + prompt.WriteString(vmRunToolingHarnessPrompt) + lines := make([]string, 0, len(plan.RepoManagedTools)+len(plan.Steps)+len(plan.Skips)) + for _, tool := range plan.RepoManagedTools { + lines = append(lines, fmt.Sprintf("- Repo already declares %s through mise", tool)) + } + for _, step := range plan.Steps { + lines = append(lines, fmt.Sprintf("- Planned deterministic install: %s@%s from %s", step.Tool, step.Version, step.Source)) + } + for _, skip := range plan.Skips { + lines = append(lines, fmt.Sprintf("- Deterministic skip: %s (%s)", skip.Target, skip.Reason)) + } + if len(lines) == 0 { + lines = append(lines, "- No deterministic prepass actions were planned") + } + prompt.WriteString("\n\nDeterministic prepass summary:\n") + prompt.WriteString(strings.Join(lines, "\n")) + prompt.WriteString("\n\nDo not repeat the deterministic prepass work unless it clearly failed. Focus on the remaining gaps.\n") + return prompt.String() +} + +func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string { + var script strings.Builder + script.WriteString("set -uo pipefail\n") + fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir(spec.RepoName))) + script.WriteString("export PATH=/usr/local/bin:/root/.local/share/mise/shims:$PATH\n") + script.WriteString("if [ -f /etc/profile.d/mise.sh ]; then . /etc/profile.d/mise.sh || true; fi\n") + script.WriteString("log() { printf '%s\\n' \"$*\"; }\n") + script.WriteString("run_best_effort() {\n") + script.WriteString(" \"$@\"\n") + script.WriteString(" rc=$?\n") + script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n") + script.WriteString(" log \"command failed ($rc): $*\"\n") + script.WriteString(" fi\n") + script.WriteString(" return 0\n") + script.WriteString("}\n") + script.WriteString("run_bounded_best_effort() {\n") + script.WriteString(" timeout_secs=\"$1\"\n") + script.WriteString(" shift\n") + script.WriteString(" timeout_marker=\"$(mktemp)\"\n") + script.WriteString(" rm -f \"$timeout_marker\"\n") + script.WriteString(" \"$@\" &\n") + script.WriteString(" cmd_pid=$!\n") + script.WriteString(" (\n") + script.WriteString(" sleep \"$timeout_secs\"\n") + script.WriteString(" if kill -0 \"$cmd_pid\" 2>/dev/null; then\n") + script.WriteString(" : >\"$timeout_marker\"\n") + script.WriteString(" log \"command timed out after ${timeout_secs}s: $*\"\n") + script.WriteString(" kill -TERM \"$cmd_pid\" 2>/dev/null || true\n") + script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -TERM -P \"$cmd_pid\" 2>/dev/null || true; fi\n") + script.WriteString(" sleep 2\n") + script.WriteString(" kill -KILL \"$cmd_pid\" 2>/dev/null || true\n") + script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -KILL -P \"$cmd_pid\" 2>/dev/null || true; fi\n") + script.WriteString(" fi\n") + script.WriteString(" ) &\n") + script.WriteString(" watchdog_pid=$!\n") + script.WriteString(" wait \"$cmd_pid\"\n") + script.WriteString(" rc=$?\n") + script.WriteString(" kill \"$watchdog_pid\" 2>/dev/null || true\n") + script.WriteString(" wait \"$watchdog_pid\" 2>/dev/null || true\n") + script.WriteString(" if [ -f \"$timeout_marker\" ]; then\n") + script.WriteString(" rm -f \"$timeout_marker\"\n") + script.WriteString(" return 0\n") + script.WriteString(" fi\n") + script.WriteString(" rm -f \"$timeout_marker\"\n") + script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n") + script.WriteString(" log \"command failed ($rc): $*\"\n") + script.WriteString(" fi\n") + script.WriteString(" return 0\n") + script.WriteString("}\n") + script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n") + script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\n") + script.WriteString("OPENCODE_BIN=\"$(command -v opencode || true)\"\n") + script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n") + script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n") + fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName))) + script.WriteString("if [ ! -f \"$PROMPT_FILE\" ]; then log \"tooling prompt file missing: $PROMPT_FILE\"; exit 0; fi\n") + script.WriteString("log \"starting tooling harness in $DIR\"\n") + script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n") + script.WriteString(" log \"running mise install from repo declarations\"\n") + script.WriteString(" run_best_effort \"$MISE_BIN\" install\n") + script.WriteString("fi\n") + fmt.Fprintf(&script, "INSTALL_TIMEOUT_SECS=%d\n", vmRunToolingInstallTimeoutSeconds) + for _, step := range plan.Steps { + stepLabel := fmt.Sprintf("deterministic install: %s@%s (%s)", step.Tool, step.Version, step.Source) + fmt.Fprintf(&script, "log %s\n", shellQuote(stepLabel)) + fmt.Fprintf(&script, "run_bounded_best_effort \"$INSTALL_TIMEOUT_SECS\" \"$MISE_BIN\" use -g --pin %s\n", shellQuote(step.Tool+"@"+step.Version)) + } + for _, skip := range plan.Skips { + skipLabel := fmt.Sprintf("deterministic skip: %s (%s)", skip.Target, skip.Reason) + fmt.Fprintf(&script, "log %s\n", shellQuote(skipLabel)) + } + if len(plan.Steps) > 0 { + script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n") + } + fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel)) + fmt.Fprintf(&script, "TIMEOUT_SECS=%d\n", vmRunToolingHarnessTimeoutSeconds) + script.WriteString("log \"running bounded opencode repo tooling inspection with $MODEL for up to ${TIMEOUT_SECS}s\"\n") + script.WriteString("run_bounded_best_effort \"$TIMEOUT_SECS\" bash -lc 'exec \"$1\" run --format json -m \"$2\" \"$(cat \"$3\")\"' _ \"$OPENCODE_BIN\" \"$MODEL\" \"$PROMPT_FILE\"\n") + script.WriteString("log \"tooling harness finished\"\n") + return script.String() +} + +func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + fmt.Fprintf(&script, "HELPER=%s\n", shellQuote(vmRunToolingHarnessPath(spec.RepoName))) + fmt.Fprintf(&script, "LOG=%s\n", shellQuote(vmRunToolingHarnessLogPath(spec.RepoName))) + script.WriteString("mkdir -p \"$(dirname \"$LOG\")\"\n") + script.WriteString("nohup bash \"$HELPER\" >\"$LOG\" 2>&1 "$LOG" 2>&1 .mise.toml", "cat > .tool-versions"} { + if strings.Contains(script, unwanted) { + t.Fatalf("script = %q, want no %q", script, unwanted) + } + } +} + func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") @@ -1538,9 +1820,24 @@ func testRunGit(t *testing.T, dir string, args ...string) string { return string(output) } +type testVMRunUpload struct { + path string + mode os.FileMode + data []byte +} + type testVMRunGuestClient struct { closed bool + uploads []testVMRunUpload + uploadPath string + uploadMode os.FileMode + uploadData []byte + uploadErr error + checkoutErr error + launchErr error script string + launchScript string + runScriptCalls int tarSourceDir string tarCommand string streamSourceDir string @@ -1553,6 +1850,15 @@ func (c *testVMRunGuestClient) Close() error { return nil } +func (c *testVMRunGuestClient) UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error { + copyData := append([]byte(nil), data...) + c.uploads = append(c.uploads, testVMRunUpload{path: remotePath, mode: mode, data: copyData}) + c.uploadPath = remotePath + c.uploadMode = mode + c.uploadData = copyData + return c.uploadErr +} + func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error { c.tarSourceDir = sourceDir c.tarCommand = remoteCommand @@ -1560,8 +1866,13 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC } func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error { - c.script = script - return nil + c.runScriptCalls++ + if c.runScriptCalls == 1 { + c.script = script + return c.checkoutErr + } + c.launchScript = script + return c.launchErr } func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error { diff --git a/internal/toolingplan/go.go b/internal/toolingplan/go.go new file mode 100644 index 0000000..9b65b72 --- /dev/null +++ b/internal/toolingplan/go.go @@ -0,0 +1,26 @@ +package toolingplan + +import ( + "context" + "fmt" +) + +type goDetector struct{} + +func (goDetector) detect(_ context.Context, repoRoot string, managedTools map[string]struct{}) detectionResult { + if alreadyManaged("go", managedTools) { + return detectionResult{Skips: []SkipNote{{Target: "go", Reason: "already managed by repo mise declarations"}}} + } + goMod, ok, err := readRepoFile(repoRoot, "go.mod") + if err != nil { + return detectionResult{Skips: []SkipNote{{Target: "go", Reason: fmt.Sprintf("could not read go.mod: %v", err)}}} + } + if !ok { + return detectionResult{Skips: []SkipNote{{Target: "go", Reason: "no go.mod"}}} + } + version, ok := parseGoDirective(goMod) + if !ok { + return detectionResult{Skips: []SkipNote{{Target: "go", Reason: "go.mod has no exact go directive"}}} + } + return detectionResult{Steps: []InstallStep{{Tool: "go", Version: version, Source: "go.mod", Reason: "go directive"}}} +} diff --git a/internal/toolingplan/mise.go b/internal/toolingplan/mise.go new file mode 100644 index 0000000..50803ca --- /dev/null +++ b/internal/toolingplan/mise.go @@ -0,0 +1,88 @@ +package toolingplan + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + toml "github.com/pelletier/go-toml" +) + +func repoManagedTools(repoRoot string) (map[string]struct{}, []SkipNote) { + tools := make(map[string]struct{}) + skips := make([]SkipNote, 0) + if err := collectToolVersions(filepath.Join(repoRoot, ".tool-versions"), tools); err != nil { + skips = append(skips, SkipNote{ + Target: "repo mise declarations", + Reason: fmt.Sprintf("could not read .tool-versions: %v", err), + }) + } + if err := collectMiseToml(filepath.Join(repoRoot, ".mise.toml"), tools); err != nil { + skips = append(skips, SkipNote{ + Target: "repo mise declarations", + Reason: fmt.Sprintf("could not parse .mise.toml: %v", err), + }) + } + return tools, skips +} + +func collectToolVersions(path string, tools map[string]struct{}) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + tools[fields[0]] = struct{}{} + } + return scanner.Err() +} + +func collectMiseToml(path string, tools map[string]struct{}) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + tree, err := toml.LoadBytes(data) + if err != nil { + return err + } + value := tree.Get("tools") + if value == nil { + return nil + } + switch typed := value.(type) { + case *toml.Tree: + for _, key := range typed.Keys() { + tools[key] = struct{}{} + } + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + tools[key] = struct{}{} + } + } + return nil +} diff --git a/internal/toolingplan/node.go b/internal/toolingplan/node.go new file mode 100644 index 0000000..41c9c54 --- /dev/null +++ b/internal/toolingplan/node.go @@ -0,0 +1,109 @@ +package toolingplan + +import ( + "context" + "fmt" + "strings" +) + +type nodeDetector struct{} + +func (nodeDetector) detect(_ context.Context, repoRoot string, managedTools map[string]struct{}) detectionResult { + result := detectionResult{} + + nodeVersion, nodeSource, nodeManaged, nodeSkip := detectNodeVersion(repoRoot, managedTools) + if nodeManaged { + result.Skips = append(result.Skips, SkipNote{Target: "node", Reason: "already managed by repo mise declarations"}) + } else if nodeVersion != "" { + result.Steps = append(result.Steps, InstallStep{Tool: "node", Version: nodeVersion, Source: nodeSource, Reason: "exact runtime version"}) + } else { + result.Skips = append(result.Skips, SkipNote{Target: "node", Reason: nodeSkip}) + } + + packageManagerVersion, packageManagerTool, packageManagerSource, packageManagerSkip := detectNodePackageManager(repoRoot) + if packageManagerTool == "" { + if packageManagerSkip != "" { + result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: packageManagerSkip}) + } + return result + } + if alreadyManaged(packageManagerTool, managedTools) { + result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: packageManagerTool + " is already managed by repo mise declarations"}) + return result + } + if nodeVersion == "" && !alreadyManaged("node", managedTools) { + result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: "packageManager is pinned but node is not pinned"}) + return result + } + result.Steps = append(result.Steps, InstallStep{Tool: packageManagerTool, Version: packageManagerVersion, Source: packageManagerSource, Reason: "exact packageManager version"}) + return result +} + +func detectNodeVersion(repoRoot string, managedTools map[string]struct{}) (version string, source string, managed bool, skip string) { + if alreadyManaged("node", managedTools) { + return "", "", true, "" + } + for _, candidate := range []string{".node-version", ".nvmrc"} { + value, ok, err := readRepoFile(repoRoot, candidate) + if err != nil { + return "", "", false, fmt.Sprintf("could not read %s: %v", candidate, err) + } + if !ok { + continue + } + version, ok := normalizeExactVersion(strings.TrimSpace(value)) + if ok { + return version, candidate, false, "" + } + return "", "", false, candidate + " does not pin an exact version" + } + packageJSON, ok, err := readRepoFile(repoRoot, "package.json") + if err != nil { + return "", "", false, fmt.Sprintf("could not read package.json: %v", err) + } + if !ok { + return "", "", false, "no pinned node version file" + } + meta, err := parsePackageJSON(packageJSON) + if err != nil { + return "", "", false, fmt.Sprintf("could not parse package.json: %v", err) + } + if version, ok := normalizeExactVersion(meta.Volta.Node); ok { + return version, "package.json#volta.node", false, "" + } + if strings.TrimSpace(meta.Volta.Node) != "" { + return "", "", false, "package.json#volta.node is not an exact version" + } + return "", "", false, "no pinned node version file" +} + +func detectNodePackageManager(repoRoot string) (version string, tool string, source string, skip string) { + packageJSON, ok, err := readRepoFile(repoRoot, "package.json") + if err != nil { + return "", "", "", fmt.Sprintf("could not read package.json: %v", err) + } + if !ok { + return "", "", "", "" + } + meta, err := parsePackageJSON(packageJSON) + if err != nil { + return "", "", "", fmt.Sprintf("could not parse package.json: %v", err) + } + value := strings.TrimSpace(meta.PackageManager) + if value == "" { + return "", "", "", "" + } + parts := strings.SplitN(value, "@", 2) + if len(parts) != 2 { + return "", "", "", "packageManager is not in tool@version form" + } + tool = strings.TrimSpace(parts[0]) + if tool != "pnpm" && tool != "yarn" && tool != "npm" && tool != "bun" { + return "", "", "", "packageManager is not a supported exact installer target" + } + version, ok = normalizeExactVersion(parts[1]) + if !ok { + return "", "", "", "packageManager version is not exact" + } + return version, tool, "package.json#packageManager", "" +} diff --git a/internal/toolingplan/plan.go b/internal/toolingplan/plan.go new file mode 100644 index 0000000..07513c8 --- /dev/null +++ b/internal/toolingplan/plan.go @@ -0,0 +1,94 @@ +package toolingplan + +import ( + "context" + "os" + "path/filepath" + "sort" +) + +type InstallStep struct { + Tool string + Version string + Source string + Reason string +} + +type SkipNote struct { + Target string + Reason string +} + +type Plan struct { + RepoManagedTools []string + Steps []InstallStep + Skips []SkipNote +} + +type detector interface { + detect(context.Context, string, map[string]struct{}) detectionResult +} + +type detectionResult struct { + Steps []InstallStep + Skips []SkipNote +} + +var detectors = []detector{ + goDetector{}, + nodeDetector{}, + pythonDetector{}, + rustDetector{}, +} + +func Build(ctx context.Context, repoRoot string) Plan { + managedTools, managedSkips := repoManagedTools(repoRoot) + steps := make([]InstallStep, 0) + skips := append([]SkipNote(nil), managedSkips...) + for _, detector := range detectors { + result := detector.detect(ctx, repoRoot, managedTools) + steps = append(steps, result.Steps...) + skips = append(skips, result.Skips...) + } + sort.Slice(steps, func(i, j int) bool { + if steps[i].Tool != steps[j].Tool { + return steps[i].Tool < steps[j].Tool + } + if steps[i].Version != steps[j].Version { + return steps[i].Version < steps[j].Version + } + return steps[i].Source < steps[j].Source + }) + sort.Slice(skips, func(i, j int) bool { + if skips[i].Target != skips[j].Target { + return skips[i].Target < skips[j].Target + } + return skips[i].Reason < skips[j].Reason + }) + repoManagedList := make([]string, 0, len(managedTools)) + for tool := range managedTools { + repoManagedList = append(repoManagedList, tool) + } + sort.Strings(repoManagedList) + return Plan{ + RepoManagedTools: repoManagedList, + Steps: steps, + Skips: skips, + } +} + +func readRepoFile(repoRoot, relativePath string) (string, bool, error) { + data, err := os.ReadFile(filepath.Join(repoRoot, relativePath)) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, err + } + return string(data), true, nil +} + +func alreadyManaged(tool string, managedTools map[string]struct{}) bool { + _, ok := managedTools[tool] + return ok +} diff --git a/internal/toolingplan/plan_test.go b/internal/toolingplan/plan_test.go new file mode 100644 index 0000000..ee4b7a7 --- /dev/null +++ b/internal/toolingplan/plan_test.go @@ -0,0 +1,137 @@ +package toolingplan + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildDetectsGoVersionFromGoMod(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, "go.mod", "module example.com/demo\n\ngo 1.25.0\n") + + plan := Build(context.Background(), repoRoot) + + if len(plan.Steps) != 1 { + t.Fatalf("steps = %#v, want one step", plan.Steps) + } + step := plan.Steps[0] + if step.Tool != "go" || step.Version != "1.25.0" || step.Source != "go.mod" { + t.Fatalf("step = %#v, want go@1.25.0 from go.mod", step) + } +} + +func TestBuildSkipsGoWhenRepoMiseAlreadyDeclaresIt(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, ".mise.toml", "[tools]\ngo = '1.25.0'\n") + writePlanFile(t, repoRoot, "go.mod", "module example.com/demo\n\ngo 1.25.0\n") + + plan := Build(context.Background(), repoRoot) + + if len(plan.Steps) != 0 { + t.Fatalf("steps = %#v, want no deterministic go install", plan.Steps) + } + if !containsSkip(plan.Skips, "go", "already managed by repo mise declarations") { + t.Fatalf("skips = %#v, want managed go skip", plan.Skips) + } + if len(plan.RepoManagedTools) != 1 || plan.RepoManagedTools[0] != "go" { + t.Fatalf("repo managed tools = %#v, want [go]", plan.RepoManagedTools) + } +} + +func TestBuildDetectsNodeAndPackageManager(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, ".node-version", "v22.14.0\n") + writePlanFile(t, repoRoot, "package.json", `{"packageManager":"pnpm@9.15.2"}`) + + plan := Build(context.Background(), repoRoot) + + if !containsStep(plan.Steps, "node", "22.14.0", ".node-version") { + t.Fatalf("steps = %#v, want node step", plan.Steps) + } + if !containsStep(plan.Steps, "pnpm", "9.15.2", "package.json#packageManager") { + t.Fatalf("steps = %#v, want pnpm step", plan.Steps) + } +} + +func TestBuildSkipsPackageManagerWhenNodeIsNotPinned(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, "package.json", `{"packageManager":"pnpm@9.15.2"}`) + + plan := Build(context.Background(), repoRoot) + + if containsStep(plan.Steps, "pnpm", "9.15.2", "package.json#packageManager") { + t.Fatalf("steps = %#v, want no package manager install", plan.Steps) + } + if !containsSkip(plan.Skips, "node package manager", "packageManager is pinned but node is not pinned") { + t.Fatalf("skips = %#v, want node package manager skip", plan.Skips) + } +} + +func TestBuildDetectsPythonAndRust(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, ".python-version", "3.12.9\n") + writePlanFile(t, repoRoot, "rust-toolchain.toml", "[toolchain]\nchannel = '1.86.0'\n") + + plan := Build(context.Background(), repoRoot) + + if !containsStep(plan.Steps, "python", "3.12.9", ".python-version") { + t.Fatalf("steps = %#v, want python step", plan.Steps) + } + if !containsStep(plan.Steps, "rust", "1.86.0", "rust-toolchain.toml") { + t.Fatalf("steps = %#v, want rust step", plan.Steps) + } +} + +func TestBuildSkipsRustChannelNames(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, "rust-toolchain.toml", "[toolchain]\nchannel = 'stable'\n") + + plan := Build(context.Background(), repoRoot) + + if !containsSkip(plan.Skips, "rust", "rust-toolchain.toml channel is not an exact version") { + t.Fatalf("skips = %#v, want rust exact-version skip", plan.Skips) + } +} + +func TestBuildReportsMalformedMiseTomlAsSkip(t *testing.T) { + repoRoot := t.TempDir() + writePlanFile(t, repoRoot, ".mise.toml", "[tools\nbroken") + + plan := Build(context.Background(), repoRoot) + + if !containsSkip(plan.Skips, "repo mise declarations", "could not parse .mise.toml") { + t.Fatalf("skips = %#v, want malformed .mise.toml skip", plan.Skips) + } +} + +func writePlanFile(t *testing.T, repoRoot, relativePath, contents string) { + t.Helper() + path := filepath.Join(repoRoot, relativePath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", relativePath, err) + } +} + +func containsStep(steps []InstallStep, tool, version, source string) bool { + for _, step := range steps { + if step.Tool == tool && step.Version == version && step.Source == source { + return true + } + } + return false +} + +func containsSkip(skips []SkipNote, target, reasonContains string) bool { + for _, skip := range skips { + if skip.Target == target && strings.Contains(skip.Reason, reasonContains) { + return true + } + } + return false +} diff --git a/internal/toolingplan/python.go b/internal/toolingplan/python.go new file mode 100644 index 0000000..6df9ef3 --- /dev/null +++ b/internal/toolingplan/python.go @@ -0,0 +1,27 @@ +package toolingplan + +import ( + "context" + "fmt" + "strings" +) + +type pythonDetector struct{} + +func (pythonDetector) detect(_ context.Context, repoRoot string, managedTools map[string]struct{}) detectionResult { + if alreadyManaged("python", managedTools) { + return detectionResult{Skips: []SkipNote{{Target: "python", Reason: "already managed by repo mise declarations"}}} + } + value, ok, err := readRepoFile(repoRoot, ".python-version") + if err != nil { + return detectionResult{Skips: []SkipNote{{Target: "python", Reason: fmt.Sprintf("could not read .python-version: %v", err)}}} + } + if !ok { + return detectionResult{Skips: []SkipNote{{Target: "python", Reason: "no .python-version"}}} + } + version, ok := normalizeExactVersion(strings.TrimSpace(value)) + if !ok { + return detectionResult{Skips: []SkipNote{{Target: "python", Reason: ".python-version does not pin an exact version"}}} + } + return detectionResult{Steps: []InstallStep{{Tool: "python", Version: version, Source: ".python-version", Reason: "exact runtime version"}}} +} diff --git a/internal/toolingplan/rust.go b/internal/toolingplan/rust.go new file mode 100644 index 0000000..ddd8090 --- /dev/null +++ b/internal/toolingplan/rust.go @@ -0,0 +1,70 @@ +package toolingplan + +import ( + "context" + "fmt" + "strings" + + toml "github.com/pelletier/go-toml" +) + +type rustDetector struct{} + +func (rustDetector) detect(_ context.Context, repoRoot string, managedTools map[string]struct{}) detectionResult { + if alreadyManaged("rust", managedTools) { + return detectionResult{Skips: []SkipNote{{Target: "rust", Reason: "already managed by repo mise declarations"}}} + } + if version, ok, reason := parseRustToolchainToml(repoRoot); ok { + return detectionResult{Steps: []InstallStep{{Tool: "rust", Version: version, Source: "rust-toolchain.toml", Reason: "exact toolchain channel"}}} + } else if reason != "" { + return detectionResult{Skips: []SkipNote{{Target: "rust", Reason: reason}}} + } + value, ok, err := readRepoFile(repoRoot, "rust-toolchain") + if err != nil { + return detectionResult{Skips: []SkipNote{{Target: "rust", Reason: fmt.Sprintf("could not read rust-toolchain: %v", err)}}} + } + if !ok { + return detectionResult{Skips: []SkipNote{{Target: "rust", Reason: "no rust-toolchain or rust-toolchain.toml"}}} + } + version := firstMeaningfulLine(value) + if normalized, ok := normalizeExactVersion(version); ok { + return detectionResult{Steps: []InstallStep{{Tool: "rust", Version: normalized, Source: "rust-toolchain", Reason: "exact toolchain channel"}}} + } + return detectionResult{Skips: []SkipNote{{Target: "rust", Reason: "rust-toolchain does not pin an exact version"}}} +} + +func parseRustToolchainToml(repoRoot string) (version string, ok bool, reason string) { + data, found, err := readRepoFile(repoRoot, "rust-toolchain.toml") + if err != nil { + return "", false, fmt.Sprintf("could not read rust-toolchain.toml: %v", err) + } + if !found { + return "", false, "" + } + tree, err := toml.Load(data) + if err != nil { + return "", false, fmt.Sprintf("could not parse rust-toolchain.toml: %v", err) + } + channelValue := tree.GetDefault("toolchain.channel", "") + channel, _ := channelValue.(string) + channel = strings.TrimSpace(channel) + if channel == "" { + return "", false, "rust-toolchain.toml has no toolchain.channel" + } + version, ok = normalizeExactVersion(channel) + if !ok { + return "", false, "rust-toolchain.toml channel is not an exact version" + } + return version, true, "" +} + +func firstMeaningfulLine(value string) string { + for _, line := range strings.Split(value, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + return trimmed + } + return "" +} diff --git a/internal/toolingplan/version.go b/internal/toolingplan/version.go new file mode 100644 index 0000000..c8c225b --- /dev/null +++ b/internal/toolingplan/version.go @@ -0,0 +1,41 @@ +package toolingplan + +import ( + "encoding/json" + "regexp" + "strings" +) + +var ( + exactVersionPattern = regexp.MustCompile(`^v?\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z.-]+)?$`) + goDirectivePattern = regexp.MustCompile(`(?m)^go\s+([0-9]+(?:\.[0-9]+){1,2})\s*$`) +) + +func normalizeExactVersion(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if !exactVersionPattern.MatchString(trimmed) { + return "", false + } + return strings.TrimPrefix(trimmed, "v"), true +} + +func parseGoDirective(goMod string) (string, bool) { + matches := goDirectivePattern.FindStringSubmatch(goMod) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + +type packageJSONMetadata struct { + PackageManager string `json:"packageManager"` + Volta struct { + Node string `json:"node"` + } `json:"volta"` +} + +func parsePackageJSON(data string) (packageJSONMetadata, error) { + var meta packageJSONMetadata + err := json.Unmarshal([]byte(data), &meta) + return meta, err +}