Bootstrap vm run tooling before attach
Speed up first use of repo backed VMs by bootstrapping obvious tools before the best effort LLM harness runs. Add a host side tooling plan for pinned Go, Node, Python, and Rust versions, summarize that plan in the uploaded prompt, and run repo mise install plus guest global mise use -g --pin steps before the bounded opencode inspection. Keep the harness non fatal, prefer host opencode attach when the client supports it, fall back to guest opencode over SSH for older clients, and cover the new flow with CLI plus planner tests. Validation: - go test ./internal/cli ./internal/toolingplan - GOCACHE=/tmp/banger-gocache go test ./... - make build
This commit is contained in:
parent
1e967140c3
commit
4813e844e2
10 changed files with 1126 additions and 13 deletions
|
|
@ -30,6 +30,7 @@ import (
|
||||||
"banger/internal/paths"
|
"banger/internal/paths"
|
||||||
"banger/internal/rpc"
|
"banger/internal/rpc"
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
|
"banger/internal/toolingplan"
|
||||||
"banger/internal/vmdns"
|
"banger/internal/vmdns"
|
||||||
"banger/internal/vsockagent"
|
"banger/internal/vsockagent"
|
||||||
|
|
||||||
|
|
@ -56,7 +57,8 @@ var (
|
||||||
opencodeCmd.Stdin = stdin
|
opencodeCmd.Stdin = stdin
|
||||||
return opencodeCmd.Run()
|
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...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -94,12 +96,14 @@ var (
|
||||||
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
||||||
return guest.Dial(ctx, address, privateKeyPath)
|
return guest.Dial(ctx, address, privateKeyPath)
|
||||||
}
|
}
|
||||||
prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy
|
||||||
cwdFunc = os.Getwd
|
buildVMRunToolingPlanFunc = toolingplan.Build
|
||||||
|
cwdFunc = os.Getwd
|
||||||
)
|
)
|
||||||
|
|
||||||
type vmRunGuestClient interface {
|
type vmRunGuestClient interface {
|
||||||
Close() error
|
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
|
RunScript(ctx context.Context, script string, logWriter io.Writer) error
|
||||||
StreamTar(ctx context.Context, sourceDir, remoteCommand 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
|
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 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 {
|
func NewBangerCommand() *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "banger",
|
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 {
|
if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil {
|
||||||
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
|
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
|
||||||
}
|
}
|
||||||
progress.render("attaching opencode")
|
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
|
||||||
if err := runVMRunAttach(ctx, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName)); 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 fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1736,16 +1758,196 @@ func vmRunGuestDir(repoName string) string {
|
||||||
return filepath.ToSlash(filepath.Join("/root", repoName))
|
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 </dev/null &\n")
|
||||||
|
script.WriteString("disown || true\n")
|
||||||
|
return script.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVMRunAttach(ctx context.Context, socketPath, vmRef string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string, progress *vmRunProgressRenderer) error {
|
||||||
guestIP = strings.TrimSpace(guestIP)
|
guestIP = strings.TrimSpace(guestIP)
|
||||||
if guestIP == "" {
|
if guestIP == "" {
|
||||||
return errors.New("vm has no guest IP")
|
return errors.New("vm has no guest IP")
|
||||||
}
|
}
|
||||||
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
|
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
|
||||||
"attach",
|
if err != nil {
|
||||||
"--dir", guestDir,
|
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
|
||||||
"http://" + net.JoinHostPort(guestIP, "4096"),
|
}
|
||||||
})
|
if supportsAttach {
|
||||||
|
if progress != nil {
|
||||||
|
progress.render("attaching opencode")
|
||||||
|
}
|
||||||
|
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
|
||||||
|
"attach",
|
||||||
|
"--dir", guestDir,
|
||||||
|
"http://" + net.JoinHostPort(guestIP, "4096"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress.render("host opencode has no attach support; starting guest opencode over ssh")
|
||||||
|
}
|
||||||
|
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
|
||||||
|
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return opencodeAttachHelpOutputSupported(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opencodeAttachHelpOutputSupported(output []byte) bool {
|
||||||
|
text := strings.ToLower(string(output))
|
||||||
|
return strings.Contains(text, "opencode attach")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatVMRunStepError(action string, err error, log string) error {
|
func formatVMRunStepError(action string, err error, log string) error {
|
||||||
|
|
@ -1789,6 +1991,14 @@ func formatVMRunProgress(detail string) string {
|
||||||
return "[vm run] " + detail
|
return "[vm run] " + detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printVMRunWarning(out io.Writer, detail string) {
|
||||||
|
detail = strings.TrimSpace(detail)
|
||||||
|
if out == nil || detail == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(out, "[vm run] warning: "+detail)
|
||||||
|
}
|
||||||
|
|
||||||
func shellQuote(value string) string {
|
func shellQuote(value string) string {
|
||||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"banger/internal/buildinfo"
|
"banger/internal/buildinfo"
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
|
"banger/internal/toolingplan"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
||||||
|
|
@ -1050,7 +1051,9 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
origWaitForSSH := guestWaitForSSHFunc
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
origGuestDial := guestDialFunc
|
origGuestDial := guestDialFunc
|
||||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
|
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
||||||
origOpencodeExec := opencodeExecFunc
|
origOpencodeExec := opencodeExecFunc
|
||||||
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
vmCreateBeginFunc = origBegin
|
vmCreateBeginFunc = origBegin
|
||||||
vmCreateStatusFunc = origStatus
|
vmCreateStatusFunc = origStatus
|
||||||
|
|
@ -1058,7 +1061,9 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
guestWaitForSSHFunc = origWaitForSSH
|
guestWaitForSSHFunc = origWaitForSSH
|
||||||
guestDialFunc = origGuestDial
|
guestDialFunc = origGuestDial
|
||||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
|
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
||||||
opencodeExecFunc = origOpencodeExec
|
opencodeExecFunc = origOpencodeExec
|
||||||
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||||
})
|
})
|
||||||
|
|
||||||
vm := model.VMRecord{
|
vm := model.VMRecord{
|
||||||
|
|
@ -1113,6 +1118,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
}
|
}
|
||||||
return repoCopyDir, func() {}, nil
|
return repoCopyDir, func() {}, nil
|
||||||
}
|
}
|
||||||
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
|
||||||
|
return toolingplan.Plan{
|
||||||
|
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||||
|
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
var attachArgs []string
|
var attachArgs []string
|
||||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||||
attachArgs = append([]string(nil), args...)
|
attachArgs = append([]string(nil), args...)
|
||||||
|
|
@ -1165,6 +1179,54 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
||||||
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
||||||
}
|
}
|
||||||
|
if len(fakeClient.uploads) != 2 {
|
||||||
|
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
|
||||||
|
}
|
||||||
|
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
|
||||||
|
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
|
||||||
|
}
|
||||||
|
if fakeClient.uploads[0].mode != 0o644 {
|
||||||
|
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
|
||||||
|
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
|
||||||
|
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
|
||||||
|
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
|
||||||
|
}
|
||||||
|
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
||||||
|
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
|
||||||
|
}
|
||||||
|
if fakeClient.uploadMode != 0o755 {
|
||||||
|
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
|
||||||
|
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
|
||||||
|
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
|
||||||
|
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
|
||||||
|
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
|
||||||
|
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
|
||||||
|
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
|
||||||
|
}
|
||||||
|
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
||||||
|
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
|
||||||
|
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
||||||
|
}
|
||||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
||||||
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
||||||
}
|
}
|
||||||
|
|
@ -1206,6 +1268,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
origGuestDial := guestDialFunc
|
origGuestDial := guestDialFunc
|
||||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
origOpencodeExec := opencodeExecFunc
|
origOpencodeExec := opencodeExecFunc
|
||||||
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
vmCreateBeginFunc = origBegin
|
vmCreateBeginFunc = origBegin
|
||||||
vmCreateStatusFunc = origStatus
|
vmCreateStatusFunc = origStatus
|
||||||
|
|
@ -1214,6 +1277,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
guestDialFunc = origGuestDial
|
guestDialFunc = origGuestDial
|
||||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
opencodeExecFunc = origOpencodeExec
|
opencodeExecFunc = origOpencodeExec
|
||||||
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||||
})
|
})
|
||||||
|
|
||||||
vm := model.VMRecord{
|
vm := model.VMRecord{
|
||||||
|
|
@ -1253,6 +1317,9 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||||
return t.TempDir(), func() {}, nil
|
return t.TempDir(), func() {}, nil
|
||||||
}
|
}
|
||||||
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -1279,6 +1346,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
"[vm run] copying repo metadata to guest",
|
"[vm run] copying repo metadata to guest",
|
||||||
"[vm run] preparing guest checkout",
|
"[vm run] preparing guest checkout",
|
||||||
"[vm run] overlaying host working tree",
|
"[vm run] overlaying host working tree",
|
||||||
|
"[vm run] starting tooling harness",
|
||||||
|
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||||
"[vm run] attaching opencode",
|
"[vm run] attaching opencode",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
|
|
@ -1287,6 +1356,219 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
|
origBegin := vmCreateBeginFunc
|
||||||
|
origStatus := vmCreateStatusFunc
|
||||||
|
origCancel := vmCreateCancelFunc
|
||||||
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
|
origGuestDial := guestDialFunc
|
||||||
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
|
origOpencodeExec := opencodeExecFunc
|
||||||
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
vmCreateBeginFunc = origBegin
|
||||||
|
vmCreateStatusFunc = origStatus
|
||||||
|
vmCreateCancelFunc = origCancel
|
||||||
|
guestWaitForSSHFunc = origWaitForSSH
|
||||||
|
guestDialFunc = origGuestDial
|
||||||
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
|
opencodeExecFunc = origOpencodeExec
|
||||||
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||||
|
})
|
||||||
|
|
||||||
|
vm := model.VMRecord{
|
||||||
|
ID: "vm-id",
|
||||||
|
Name: "devbox",
|
||||||
|
Runtime: model.VMRuntime{
|
||||||
|
State: model.VMStateRunning,
|
||||||
|
GuestIP: "172.16.0.2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||||
|
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
||||||
|
}
|
||||||
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
||||||
|
t.Fatal("vmCreateStatusFunc should not be called")
|
||||||
|
return api.VMCreateStatusResult{}, nil
|
||||||
|
}
|
||||||
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
||||||
|
t.Fatal("vmCreateCancelFunc should not be called")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fakeClient := &testVMRunGuestClient{launchErr: errors.New("launch failed")}
|
||||||
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
||||||
|
return fakeClient, nil
|
||||||
|
}
|
||||||
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||||
|
return t.TempDir(), func() {}, nil
|
||||||
|
}
|
||||||
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
attachCalled := false
|
||||||
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||||
|
attachCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
err := runVMRun(
|
||||||
|
context.Background(),
|
||||||
|
"/tmp/bangerd.sock",
|
||||||
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||||
|
strings.NewReader(""),
|
||||||
|
&bytes.Buffer{},
|
||||||
|
&stderr,
|
||||||
|
api.VMCreateParams{Name: "devbox"},
|
||||||
|
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runVMRun: %v", err)
|
||||||
|
}
|
||||||
|
if !attachCalled {
|
||||||
|
t.Fatal("opencode attach should still run when tooling harness launch fails")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
|
||||||
|
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
|
||||||
|
origBegin := vmCreateBeginFunc
|
||||||
|
origStatus := vmCreateStatusFunc
|
||||||
|
origCancel := vmCreateCancelFunc
|
||||||
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
|
origGuestDial := guestDialFunc
|
||||||
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
|
origOpencodeExec := opencodeExecFunc
|
||||||
|
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||||
|
origSSHExec := sshExecFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
vmCreateBeginFunc = origBegin
|
||||||
|
vmCreateStatusFunc = origStatus
|
||||||
|
vmCreateCancelFunc = origCancel
|
||||||
|
guestWaitForSSHFunc = origWaitForSSH
|
||||||
|
guestDialFunc = origGuestDial
|
||||||
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
|
opencodeExecFunc = origOpencodeExec
|
||||||
|
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||||
|
sshExecFunc = origSSHExec
|
||||||
|
})
|
||||||
|
|
||||||
|
vm := model.VMRecord{
|
||||||
|
ID: "vm-id",
|
||||||
|
Name: "devbox",
|
||||||
|
Runtime: model.VMRuntime{
|
||||||
|
State: model.VMStateRunning,
|
||||||
|
GuestIP: "172.16.0.2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||||
|
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
||||||
|
}
|
||||||
|
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
||||||
|
t.Fatal("vmCreateStatusFunc should not be called")
|
||||||
|
return api.VMCreateStatusResult{}, nil
|
||||||
|
}
|
||||||
|
vmCreateCancelFunc = func(context.Context, string, string) error {
|
||||||
|
t.Fatal("vmCreateCancelFunc should not be called")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
||||||
|
return &testVMRunGuestClient{}, nil
|
||||||
|
}
|
||||||
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||||
|
return t.TempDir(), func() {}, nil
|
||||||
|
}
|
||||||
|
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||||
|
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sshArgs []string
|
||||||
|
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||||
|
sshArgs = append([]string(nil), args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
err := runVMRun(
|
||||||
|
context.Background(),
|
||||||
|
"/tmp/bangerd.sock",
|
||||||
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||||
|
strings.NewReader(""),
|
||||||
|
&bytes.Buffer{},
|
||||||
|
&stderr,
|
||||||
|
api.VMCreateParams{Name: "devbox"},
|
||||||
|
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runVMRun: %v", err)
|
||||||
|
}
|
||||||
|
if len(sshArgs) < 3 {
|
||||||
|
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
|
||||||
|
}
|
||||||
|
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
|
||||||
|
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
|
||||||
|
}
|
||||||
|
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
|
||||||
|
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
|
||||||
|
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
|
||||||
|
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
|
||||||
|
t.Fatal("expected attach help output to be recognized")
|
||||||
|
}
|
||||||
|
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
|
||||||
|
t.Fatal("unexpected attach support for top-level help output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
||||||
|
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
||||||
|
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||||
|
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||||
|
})
|
||||||
|
for _, want := range []string{
|
||||||
|
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
|
||||||
|
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
|
||||||
|
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
|
||||||
|
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
|
||||||
|
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
|
||||||
|
`run_best_effort "$MISE_BIN" install`,
|
||||||
|
`deterministic install: go@1.25.0 (go.mod)`,
|
||||||
|
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
|
||||||
|
`deterministic skip: python (no .python-version)`,
|
||||||
|
`run_best_effort "$MISE_BIN" reshim`,
|
||||||
|
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
|
||||||
|
`command timed out after ${timeout_secs}s: $*`,
|
||||||
|
`tooling prompt file missing: $PROMPT_FILE`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Fatalf("script = %q, want %q", script, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
|
||||||
|
if strings.Contains(script, unwanted) {
|
||||||
|
t.Fatalf("script = %q, want no %q", script, unwanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
||||||
if _, err := exec.LookPath("git"); err != nil {
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
t.Skip("git not installed")
|
t.Skip("git not installed")
|
||||||
|
|
@ -1538,9 +1820,24 @@ func testRunGit(t *testing.T, dir string, args ...string) string {
|
||||||
return string(output)
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testVMRunUpload struct {
|
||||||
|
path string
|
||||||
|
mode os.FileMode
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
type testVMRunGuestClient struct {
|
type testVMRunGuestClient struct {
|
||||||
closed bool
|
closed bool
|
||||||
|
uploads []testVMRunUpload
|
||||||
|
uploadPath string
|
||||||
|
uploadMode os.FileMode
|
||||||
|
uploadData []byte
|
||||||
|
uploadErr error
|
||||||
|
checkoutErr error
|
||||||
|
launchErr error
|
||||||
script string
|
script string
|
||||||
|
launchScript string
|
||||||
|
runScriptCalls int
|
||||||
tarSourceDir string
|
tarSourceDir string
|
||||||
tarCommand string
|
tarCommand string
|
||||||
streamSourceDir string
|
streamSourceDir string
|
||||||
|
|
@ -1553,6 +1850,15 @@ func (c *testVMRunGuestClient) Close() error {
|
||||||
return nil
|
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 {
|
func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error {
|
||||||
c.tarSourceDir = sourceDir
|
c.tarSourceDir = sourceDir
|
||||||
c.tarCommand = remoteCommand
|
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 {
|
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
|
||||||
c.script = script
|
c.runScriptCalls++
|
||||||
return nil
|
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 {
|
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
|
||||||
|
|
|
||||||
26
internal/toolingplan/go.go
Normal file
26
internal/toolingplan/go.go
Normal file
|
|
@ -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"}}}
|
||||||
|
}
|
||||||
88
internal/toolingplan/mise.go
Normal file
88
internal/toolingplan/mise.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
109
internal/toolingplan/node.go
Normal file
109
internal/toolingplan/node.go
Normal file
|
|
@ -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", ""
|
||||||
|
}
|
||||||
94
internal/toolingplan/plan.go
Normal file
94
internal/toolingplan/plan.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
137
internal/toolingplan/plan_test.go
Normal file
137
internal/toolingplan/plan_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
27
internal/toolingplan/python.go
Normal file
27
internal/toolingplan/python.go
Normal file
|
|
@ -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"}}}
|
||||||
|
}
|
||||||
70
internal/toolingplan/rust.go
Normal file
70
internal/toolingplan/rust.go
Normal file
|
|
@ -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 ""
|
||||||
|
}
|
||||||
41
internal/toolingplan/version.go
Normal file
41
internal/toolingplan/version.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue