diff --git a/.gitignore b/.gitignore index a2c3b1a..452b32a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ coverage.out # but the nvy repo itself uses go.mod / toolchain for its own Go version. .go-version .node-version +.php-version .python-version diff --git a/.golangci.yml b/.golangci.yml index 366252d..25b6a5f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,45 @@ version: "2" +linters: + enable: + # Correctness / safety (superset of golangci-lint's defaults, made explicit). + - errcheck # unchecked errors + - govet # suspicious constructs + - ineffassign # unused assignments + - staticcheck # broad static analysis + - unused # unused code + - gosec # security issues (path traversal, weak crypto, etc.) + - bodyclose # HTTP response bodies must be closed + - misspell # spelling in comments/strings + - unconvert # redundant type conversions + settings: + gosec: + excludes: + # G115: integer overflow on conversions — noisy on deliberate, bounded + # conversions (e.g. signal numbers, size math). Re-enable if needed. + - G115 + # The rules below fire on operations that ARE nvy's core job. Excluding + # them keeps gosec useful for real findings (weak crypto, hardcoded creds, + # SQL/command injection) instead of drowning in unavoidable noise. + # + # G304 (file inclusion via variable): nvy opens/creates files at computed + # paths everywhere — the downloaded archive, extracted entries (guarded + # by the Zip-Slip check in internal/archive), the file it hashes. + # G305 (archive path traversal): internal/archive implements its own + # containment check against the destination root that gosec can't see. + # G204 (subprocess with variable): the shim's whole purpose is exec'ing the + # resolved runtime binary from nvy's own install tree. + - G304 + - G305 + - G204 + exclusions: + rules: + # Test fixtures legitimately use loose perms and variable paths to build + # throwaway archives/dirs under t.TempDir(); gosec's file checks add no value there. + - path: _test\.go + linters: + - gosec + formatters: enable: - gofmt diff --git a/Makefile b/Makefile index b87592d..f9c4614 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ LDFLAGS := -s -w -X github.com/trevorphillipscoding/nvy/cmd.Version=$ COVERAGE_THRESHOLD := 65 COVER_PKGS := ./internal/...,./plugins/... -.PHONY: all build install test lint cover cover-check tidy clean deps help +.PHONY: all build install test test-local test-local-full lint cover cover-check tidy clean deps help all: build @@ -31,6 +31,14 @@ install: test: go test ./... +## test-local: build and drive the whole CLI end-to-end in a throwaway sandbox +test-local: + ./scripts/local-test.sh + +## test-local-full: like test-local, plus a real network install of Go +test-local-full: + ./scripts/local-test.sh --full + ## lint: run golangci-lint (install: https://github.com/golangci/golangci-lint) lint: golangci-lint run ./... diff --git a/README.md b/README.md index 80c52a0..285c561 100644 --- a/README.md +++ b/README.md @@ -149,14 +149,21 @@ If the version being removed is the active global, its shims are cleaned up auto ## Supported runtimes -| Tool | Aliases | Platforms | Source | -| -------- | ------------------- | ------------ | -------------------------------- | -| `go` | `golang` | macOS, Linux | dl.google.com | -| `node` | `nodejs`, `node.js` | macOS, Linux | nodejs.org | -| `python` | `python3`, `py` | macOS, Linux | python-build-standalone (GitHub) | +| Tool | Aliases | Platforms | Source | +| -------- | ------------------- | ------------ | ---------------------------------- | +| `go` | `golang` | macOS, Linux | dl.google.com | +| `node` | `nodejs`, `node.js` | macOS, Linux | nodejs.org | +| `php` | — | macOS, Linux | static-php-cli (dl.static-php.dev) | +| `python` | `python3`, `py` | macOS, Linux | python-build-standalone (GitHub) | Supported architectures: `amd64` (x86-64), `arm64` (Apple Silicon / ARM). +> **PHP note:** static-php-cli ships self-contained `php` binaries but publishes no +> checksums, so — unlike every other runtime — PHP downloads are **not** SHA-256 +> verified; their integrity rests on nvy's strict HTTPS/TLS enforcement alone, and +> install prints a notice to that effect. Versions are discovered live from the +> mirror; run `nvy versions php` to see what's installable. + Adding a new runtime is straightforward — see [Contributing](#contributing). --- @@ -198,7 +205,7 @@ That's it. The core install/global/local/list/uninstall commands work automatica - **HTTPS only** — plain HTTP is rejected; redirects to HTTP are blocked - **TLS 1.2+** — enforced on all connections -- **SHA-256 verification** — every archive is verified before extraction +- **SHA-256 verification** — every archive is verified before extraction (except PHP, whose upstream publishes no checksum — see the PHP note under [Supported runtimes](#supported-runtimes)) - **Zip Slip protection** — archive entries that escape the destination are rejected - **Decompression bomb protection** — 2 GB per-file limit during extraction - **Atomic installs** — temp directory + rename ensures no partial state diff --git a/cmd/commands_test.go b/cmd/commands_test.go new file mode 100644 index 0000000..d1e5451 --- /dev/null +++ b/cmd/commands_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/state" +) + +// setupInstalledTool creates a fake installed runtime under NVY_DIR, registers +// its shim, and (optionally) sets it as the global version. It chdirs to a clean +// temp directory so a stray .-version file in the real cwd can't leak into +// resolution during the test. +func setupInstalledTool(t *testing.T, tool, version string, binaries ...string) { + t.Helper() + base := t.TempDir() + t.Setenv("NVY_DIR", base) + t.Chdir(t.TempDir()) // clean cwd with no version-pin files + + binDir := env.RuntimeBinDir(tool, version) + if err := os.MkdirAll(binDir, 0750); err != nil { + t.Fatal(err) + } + for _, b := range binaries { + if err := os.WriteFile(filepath.Join(binDir, b), []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + } + if _, err := state.RegisterShims(tool, binaries); err != nil { + t.Fatal(err) + } + if err := state.SetGlobal(tool, version); err != nil { + t.Fatal(err) + } +} + +// captureStdout runs fn with os.Stdout redirected and returns what it wrote. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + _ = w.Close() + out, _ := io.ReadAll(r) + return string(out) +} + +func TestRunWhich_ResolvesToInstalledPath(t *testing.T) { + setupInstalledTool(t, "go", "1.22.1", "go", "gofmt") + + out := captureStdout(t, func() { + if err := runWhich(nil, []string{"go"}); err != nil { + t.Fatalf("runWhich: %v", err) + } + }) + + want := env.RuntimeBinDir("go", "1.22.1") + "/go" + if strings.TrimSpace(out) != want { + t.Errorf("runWhich(go) = %q; want %q", strings.TrimSpace(out), want) + } +} + +func TestRunWhich_UnregisteredBinary(t *testing.T) { + t.Setenv("NVY_DIR", t.TempDir()) + if err := runWhich(nil, []string{"nope"}); err == nil { + t.Error("expected error for unregistered binary, got nil") + } +} + +func TestCurrentLine_GlobalSource(t *testing.T) { + setupInstalledTool(t, "go", "1.22.1", "go") + + cwd, _ := os.Getwd() + line, configured := currentLine("go", cwd) + if !configured { + t.Fatal("expected go to be configured") + } + if !strings.Contains(line, "1.22.1") || !strings.Contains(line, "global") { + t.Errorf("currentLine = %q; want it to mention 1.22.1 and global", line) + } +} + +func TestCurrentLine_LocalOverridesGlobal(t *testing.T) { + setupInstalledTool(t, "go", "1.22.1", "go") + + // Pin a directory-local version equal to the installed one so resolution + // still succeeds, but the source should now read as local. + cwd, _ := os.Getwd() + if err := os.WriteFile(filepath.Join(cwd, ".go-version"), []byte("1.22.1\n"), 0644); err != nil { + t.Fatal(err) + } + + line, configured := currentLine("go", cwd) + if !configured { + t.Fatal("expected go to be configured") + } + if !strings.Contains(line, "local") { + t.Errorf("currentLine = %q; want it to report a local source", line) + } +} + +func TestCurrentLine_Unconfigured(t *testing.T) { + t.Setenv("NVY_DIR", t.TempDir()) + t.Chdir(t.TempDir()) + if _, configured := currentLine("go", "."); configured { + t.Error("expected go to be unconfigured with empty state") + } +} diff --git a/cmd/current.go b/cmd/current.go new file mode 100644 index 0000000..7730f55 --- /dev/null +++ b/cmd/current.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/trevorphillipscoding/nvy/internal/shim" + "github.com/trevorphillipscoding/nvy/internal/state" + "github.com/trevorphillipscoding/nvy/plugins" +) + +var currentCmd = &cobra.Command{ + Use: "current [tool]", + Short: "Show the active version for a tool (and where it came from)", + Long: `Report which version is currently active and why. + +Resolution order matches the shim: a .-version file (searched upward from +the current directory) wins over the global default set with "nvy global". + +With no argument, every tool that has a version configured is listed. + +Examples: + nvy current # all configured tools + nvy current go + nvy current node`, + Args: cobra.MaximumNArgs(1), + RunE: runCurrent, +} + +func runCurrent(_ *cobra.Command, args []string) error { + cwd, _ := os.Getwd() + + if len(args) == 1 { + p, err := plugins.Get(args[0]) + if err != nil { + return err + } + line, configured := currentLine(p.Name(), cwd) + if !configured { + return fmt.Errorf("no version configured for %s — set one: nvy global %s ", p.Name(), p.Name()) + } + fmt.Println(line) + return nil + } + + // No tool given: report every tool that has a local or global setting. + printed := false + for _, name := range configuredTools(cwd) { + if line, ok := currentLine(name, cwd); ok { + fmt.Println(line) + printed = true + } + } + if !printed { + fmt.Println("no versions configured — run: nvy global ") + } + return nil +} + +// currentLine formats the active version for tool as a single display line and +// reports whether any version is configured at all. Source is shown so the user +// knows whether a directory-local pin is overriding their global default. +func currentLine(tool, cwd string) (line string, configured bool) { + local := shim.FindLocalVersion(tool, cwd) + _, hasGlobal := state.GetGlobal(tool) + if local == "" && !hasGlobal { + return "", false + } + + source := "global" + if local != "" { + source = "local (." + tool + "-version)" + } + + // ResolveVersion resolves the configured value against installed versions. + // If it fails (configured but not installed), surface that rather than a path. + resolved, err := shim.ResolveVersion(tool) + if err != nil { + return fmt.Sprintf("%-8s (%s) — not installed: %v", tool, source, err), true + } + return fmt.Sprintf("%-8s %s (%s)", tool, resolved, source), true +} + +// configuredTools returns the set of tools worth reporting in the no-arg case: +// every registered plugin plus any extra tool directory on disk that has a +// global or local version set. Order is stable (plugins first, then extras). +func configuredTools(cwd string) []string { + seen := map[string]bool{} + var out []string + add := func(name string) { + if !seen[name] { + seen[name] = true + out = append(out, name) + } + } + for _, p := range plugins.All() { + add(p.Name()) + } + for _, name := range extraToolsOnDisk(plugins.All()) { + add(name) + } + // Include any tool that has a global entry but no plugin/dir (defensive). + if globals, err := state.AllGlobals(); err == nil { + for name := range globals { + add(name) + } + } + return out +} diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 0000000..42d4f6c --- /dev/null +++ b/cmd/exec.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/trevorphillipscoding/nvy/internal/shim" +) + +var execCmd = &cobra.Command{ + Use: "exec [args...]", + Short: "Run a managed binary through nvy without needing shims on PATH", + Long: `Resolve a managed binary to the active version and run it directly. + +This is the same resolution the shims perform, but invoked explicitly — handy +when ~/.nvy/shims is not on your PATH, or in scripts that want to pin behaviour +to nvy regardless of PATH order. + +All arguments after the binary are passed through untouched, including flags: + + nvy exec go build ./... + nvy exec node --version + nvy exec python -c 'print(1)'`, + // Pass every token after "exec" straight through so flags like --version are + // handed to the target binary instead of being parsed by cobra. + DisableFlagParsing: true, + RunE: runExec, +} + +func runExec(cmd *cobra.Command, args []string) error { + // With flag parsing disabled we must handle help ourselves. + if len(args) == 0 { + return fmt.Errorf("usage: nvy exec [args...]") + } + if args[0] == "-h" || args[0] == "--help" { + return cmd.Help() + } + + // shim.Run replaces this process via syscall.Exec on success and calls + // os.Exit on failure, so control never returns past this point. + shim.Run(args[0], args[1:]) + return nil +} diff --git a/cmd/global.go b/cmd/global.go index 4490aa6..ff4ab1c 100644 --- a/cmd/global.go +++ b/cmd/global.go @@ -62,11 +62,11 @@ func runGlobal(_ *cobra.Command, args []string) error { } nvyBinDir := env.ShimsDir() - if err := os.MkdirAll(nvyBinDir, 0755); err != nil { + if err := os.MkdirAll(nvyBinDir, 0750); err != nil { return fmt.Errorf("creating %s: %w", nvyBinDir, err) } - created, err := createShims(runtimeBinDir, nvyBinDir, nvyExe, tool) + created, conflicts, err := createShims(runtimeBinDir, nvyBinDir, nvyExe, tool) if err != nil { return fmt.Errorf("creating shims: %w", err) } @@ -80,6 +80,16 @@ func runGlobal(_ *cobra.Command, args []string) error { fmt.Printf(" binaries: %s\n", strings.Join(created, ", ")) } + // A binary name provided by more than one runtime can only resolve to one + // tool. Report the takeover so the shadowing isn't silent — otherwise the + // user's previously-working command would quietly start running a different + // runtime's binary. + for _, c := range conflicts { + fmt.Fprintf(os.Stderr, + " warning: %q now resolves to %s (was: %s) — both runtimes provide it\n", + c.Binary, tool, c.PreviousOwner) + } + // Warn when ~/.nvy/bin is not yet in PATH — common first-time gotcha. if !nvyBinInPath(nvyBinDir) { fmt.Printf("\n PATH not configured. Add this to your shell profile:\n") @@ -92,14 +102,12 @@ func runGlobal(_ *cobra.Command, args []string) error { // Each symlink points to nvyExe (the nvy binary), not directly to the runtime binary. // This is the shim mechanism: when invoked as "go", nvy detects the name and // resolves the correct version at runtime. -func createShims(runtimeBinDir, nvyBinDir, nvyExe, tool string) ([]string, error) { +func createShims(runtimeBinDir, nvyBinDir, nvyExe, tool string) (created []string, conflicts []state.ShimConflict, err error) { entries, err := os.ReadDir(runtimeBinDir) if err != nil { - return nil, fmt.Errorf("reading %s: %w", runtimeBinDir, err) + return nil, nil, fmt.Errorf("reading %s: %w", runtimeBinDir, err) } - var created []string - for _, e := range entries { if e.IsDir() { continue @@ -116,16 +124,17 @@ func createShims(runtimeBinDir, nvyBinDir, nvyExe, tool string) ([]string, error dst := filepath.Join(nvyBinDir, e.Name()) _ = os.Remove(dst) // replace any existing symlink or file if err := os.Symlink(nvyExe, dst); err != nil { - return nil, fmt.Errorf("creating shim for %s: %w", e.Name(), err) + return nil, nil, fmt.Errorf("creating shim for %s: %w", e.Name(), err) } created = append(created, e.Name()) } // Record binary → tool ownership so the shim knows which version file to check. - if err := state.RegisterShims(tool, created); err != nil { - return nil, fmt.Errorf("registering shim owners: %w", err) + conflicts, err = state.RegisterShims(tool, created) + if err != nil { + return nil, nil, fmt.Errorf("registering shim owners: %w", err) } - return created, nil + return created, conflicts, nil } // nvyBinInPath reports whether dir appears in the current PATH. diff --git a/cmd/install.go b/cmd/install.go index 175218f..8f61f49 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "os" + "os/signal" "path/filepath" "strings" + "syscall" "github.com/spf13/cobra" @@ -74,6 +76,12 @@ func runInstall(_ *cobra.Command, args []string) error { } defer func() { _ = os.RemoveAll(tmpDir) }() + // A defer alone doesn't run if the user hits Ctrl-C mid-download, which would + // leave the (potentially large) staging dir behind. Trap the interrupt, clean + // up, and re-raise the default behaviour so the exit status is still correct. + stopSignals := trapInterrupt(func() { _ = os.RemoveAll(tmpDir) }) + defer stopSignals() + archivePath := filepath.Join(tmpDir, "archive.tar.gz") // ── Step 1: Download ──────────────────────────────────────────────────── @@ -84,20 +92,32 @@ func runInstall(_ *cobra.Command, args []string) error { } // ── Step 2: Verify checksum ───────────────────────────────────────────── - sha256, err := fetch.ResolveChecksum(spec.SHA256, spec.ChecksumURL, spec.ChecksumFilename) - if err != nil { - return fmt.Errorf("fetching checksum: %w", err) - } - fmt.Printf("verifying checksum\n") - if err := fetch.VerifySHA256(archivePath, sha256); err != nil { - return err + if spec.SkipChecksum { + // The plugin opts out only when upstream publishes no checksum at all; + // integrity then rests on fetch's strict HTTPS/TLS enforcement. + fmt.Printf("no upstream checksum published — integrity verified via HTTPS/TLS only\n") + } else { + sha256, err := fetch.ResolveChecksum(spec.SHA256, spec.ChecksumURL, spec.ChecksumFilename) + if err != nil { + return fmt.Errorf("fetching checksum: %w", err) + } + fmt.Printf("verifying checksum\n") + if err := fetch.VerifySHA256(archivePath, sha256); err != nil { + return err + } + fmt.Printf(" checksum OK\n") } - fmt.Printf(" checksum OK\n") // ── Step 3: Extract ───────────────────────────────────────────────────── fmt.Printf("extracting\n") extractDir := filepath.Join(tmpDir, "extracted") - if err := archive.ExtractTarGz(archivePath, extractDir, spec.StripComponents); err != nil { + // Some runtimes ship a bare binary with no bin/ directory; ExtractSubdir + // redirects the payload under / so shims can find it. + extractInto := extractDir + if spec.ExtractSubdir != "" { + extractInto = filepath.Join(extractDir, spec.ExtractSubdir) + } + if err := archive.ExtractTarGz(archivePath, extractInto, spec.StripComponents); err != nil { return fmt.Errorf("extraction failed: %w", err) } @@ -110,3 +130,28 @@ func runInstall(_ *cobra.Command, args []string) error { fmt.Printf(" run: nvy global %s %s\n", tool, ver) return nil } + +// trapInterrupt runs cleanup once if the process receives SIGINT or SIGTERM, then +// exits with the conventional 128+signal status. It returns a stop function that +// unregisters the handler; call it (via defer) so a normal return doesn't leave a +// goroutine or signal registration dangling. +func trapInterrupt(cleanup func()) (stop func()) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig, ok := <-ch + if !ok { + return // channel closed by stop() — normal completion + } + cleanup() + fmt.Fprintf(os.Stderr, "\ninterrupted (%s) — cleaned up staging files\n", sig) + if s, ok := sig.(syscall.Signal); ok { + os.Exit(128 + int(s)) + } + os.Exit(1) + }() + return func() { + signal.Stop(ch) + close(ch) + } +} diff --git a/cmd/local.go b/cmd/local.go index ca2382e..34bae52 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -56,7 +56,9 @@ func runLocal(_ *cobra.Command, args []string) error { } filename := "." + tool + "-version" - if err := os.WriteFile(filename, []byte(ver+"\n"), 0644); err != nil { + // 0644 is intentional: version-pin files (.go-version, .node-version, …) are + // project config, usually committed and read by teammates and other tooling. + if err := os.WriteFile(filename, []byte(ver+"\n"), 0644); err != nil { //nolint:gosec // world-readable by design return fmt.Errorf("writing %s: %w", filename, err) } diff --git a/cmd/root.go b/cmd/root.go index 2c755ab..21b42ad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/trevorphillipscoding/nvy/internal/env" ) // Version is set during build with -ldflags "-X github.com/trevorphillipscoding/nvy/cmd.Version=..." @@ -28,6 +30,10 @@ Add ~/.nvy/shims to your PATH to use managed runtimes: // Execute is the entry point called by main. func Execute() { + // Best-effort cleanup of junk left by interrupted installs. Cheap (a couple of + // directory reads) and only runs on the CLI path, never the fast shim path. + env.SweepStale() + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) @@ -40,4 +46,8 @@ func init() { rootCmd.AddCommand(localCmd) rootCmd.AddCommand(listCmd) rootCmd.AddCommand(uninstallCmd) + rootCmd.AddCommand(whichCmd) + rootCmd.AddCommand(currentCmd) + rootCmd.AddCommand(execCmd) + rootCmd.AddCommand(versionsCmd) } diff --git a/cmd/versions.go b/cmd/versions.go new file mode 100644 index 0000000..7663f9d --- /dev/null +++ b/cmd/versions.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/semver" + "github.com/trevorphillipscoding/nvy/plugins" +) + +var versionsInstalledOnly bool + +var versionsCmd = &cobra.Command{ + Use: "versions ", + Short: "List versions available to install for a tool", + Long: `List every version a tool can install, newest first. + +This queries the runtime's release index over the network, so it complements +"nvy list" (which shows only what is already installed). Versions you already +have are marked with "*". + +Examples: + nvy versions go + nvy versions node + nvy versions node --installed # filter to just what's installed`, + Args: cobra.ExactArgs(1), + RunE: runVersions, +} + +func init() { + versionsCmd.Flags().BoolVar(&versionsInstalledOnly, "installed", false, + "show only versions that are already installed") +} + +func runVersions(_ *cobra.Command, args []string) error { + p, err := plugins.Get(args[0]) + if err != nil { + return err + } + tool := p.Name() + + available, err := p.AvailableVersions(env.OS(), env.Arch()) + if err != nil { + return fmt.Errorf("fetching available %s versions: %w", tool, err) + } + if len(available) == 0 { + fmt.Printf("no versions available for %s\n", tool) + return nil + } + semver.SortStringsDesc(available) + + // Build a lookup of installed versions so we can mark them. A missing runtime + // directory just means nothing is installed yet — not an error. + installed := map[string]bool{} + if vs, err := env.InstalledVersions(tool); err == nil { + for _, v := range vs { + installed[v] = true + } + } + + fmt.Printf("%s (* = installed)\n", tool) + for _, v := range available { + if versionsInstalledOnly && !installed[v] { + continue + } + prefix := " " + if installed[v] { + prefix = "* " + } + fmt.Printf(" %s%s\n", prefix, v) + } + return nil +} diff --git a/cmd/which.go b/cmd/which.go new file mode 100644 index 0000000..c96fa3d --- /dev/null +++ b/cmd/which.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/shim" + "github.com/trevorphillipscoding/nvy/internal/state" +) + +var whichCmd = &cobra.Command{ + Use: "which ", + Short: "Show the real path a shimmed binary resolves to", + Long: `Print the absolute path that a managed binary currently resolves to. + +This performs the exact same resolution the shim does when you run the binary +(local .-version, then the global default), so it answers "which version +would run right now, and where does it live on disk?". + +Examples: + nvy which go + nvy which node`, + Args: cobra.ExactArgs(1), + RunE: runWhich, +} + +func runWhich(_ *cobra.Command, args []string) error { + binary := args[0] + + tool, ok := state.LookupShim(binary) + if !ok { + return fmt.Errorf("no shim registered for %q — run: nvy global ", binary) + } + + version, err := shim.ResolveVersion(tool) + if err != nil { + return err + } + + binPath := filepath.Join(env.RuntimeBinDir(tool, version), binary) + if _, err := os.Stat(binPath); err != nil { + return fmt.Errorf( + "%s resolves to %s %s but %s is missing — run: nvy install %s %s", + binary, tool, version, binPath, tool, version) + } + + fmt.Println(binPath) + return nil +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 88de389..d6c34c4 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -101,7 +101,7 @@ func ExtractTarGz(src, dest string, stripComponents int) error { return fmt.Errorf("symlink %q → %q escapes destination — aborting", hdr.Name, hdr.Linkname) } // Ensure parent directory exists (some tarballs order symlinks before dirs). - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil { return fmt.Errorf("creating parent directory for symlink %s: %w", target, err) } // Remove any existing file/link at target before creating the new link. @@ -111,10 +111,21 @@ func ExtractTarGz(src, dest string, stripComponents int) error { } case tar.TypeLink: - // Hard links: resolve the link target within dest. - linkSrc := filepath.Join(dest, filepath.FromSlash( - stripLeadingComponents(hdr.Linkname, stripComponents), - )) + // Hard links reference another entry by its (stripped) archive path. + // Apply the same escape check as symlinks: a crafted linkname could + // otherwise point os.Link at a file outside dest. + rel := stripLeadingComponents(hdr.Linkname, stripComponents) + if rel == "" { + return fmt.Errorf("hard link %q → %q has an out-of-tree target — aborting", hdr.Name, hdr.Linkname) + } + linkSrc := filepath.Join(dest, filepath.FromSlash(rel)) + linkSrcAbs, err := filepath.Abs(linkSrc) + if err != nil { + return fmt.Errorf("resolving hard link %q: %w", hdr.Name, err) + } + if !strings.HasPrefix(linkSrcAbs, destAbs+string(os.PathSeparator)) { + return fmt.Errorf("hard link %q → %q escapes destination — aborting", hdr.Name, hdr.Linkname) + } _ = os.Remove(target) if err := os.Link(linkSrc, target); err != nil { return fmt.Errorf("creating hard link %s: %w", target, err) @@ -130,7 +141,7 @@ func ExtractTarGz(src, dest string, stripComponents int) error { // extractFile writes an individual regular file from the tar stream. func extractFile(r io.Reader, dest string, mode os.FileMode) error { // Ensure the parent directory exists (some tarballs omit directory entries). - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil { return fmt.Errorf("creating parent directory: %w", err) } diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index f41c8b2..919f593 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -91,6 +91,33 @@ func TestExtractTarGz_ZipSlipRejected(t *testing.T) { } } +func TestExtractTarGz_HardLinkEscapeRejected(t *testing.T) { + // A hard link whose target, after strip, points outside the destination must + // be rejected — mirroring the symlink guard. + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{ + Name: "prefix/evil-link", + Linkname: "prefix/../../../../etc/passwd", + Typeflag: tar.TypeLink, + Mode: 0644, + }); err != nil { + t.Fatal(err) + } + _ = tw.Close() + _ = gz.Close() + + src := filepath.Join(t.TempDir(), "hardlink.tar.gz") + if err := os.WriteFile(src, buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } + + if err := ExtractTarGz(src, t.TempDir(), 1); err == nil { + t.Fatal("expected escaping hard link to be rejected, got nil error") + } +} + func TestStripLeadingComponents(t *testing.T) { cases := []struct { name string diff --git a/internal/env/env.go b/internal/env/env.go index 5593431..00aee91 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -17,8 +17,15 @@ import ( "os" "path/filepath" "runtime" + "strings" + "time" ) +// staleTmpAge is how old a staging dir under ~/.nvy/tmp must be before SweepStale +// removes it. The window is generous so a concurrent, still-running install is +// never reaped out from under itself. +const staleTmpAge = 24 * time.Hour + // NvyDir returns the root nvy directory (~/.nvy). func NvyDir() string { if override := os.Getenv("NVY_DIR"); override != "" { @@ -91,7 +98,7 @@ func MkTempDir() (string, error) { // This ensures dst is always either the old or the new version; never a partial state. // Both paths must be on the same filesystem (both live under ~/.nvy/). func AtomicInstall(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { return fmt.Errorf("creating parent dir: %w", err) } @@ -117,9 +124,13 @@ func AtomicInstall(src, dst string) error { return fmt.Errorf("moving install into place: %w", err) } - // Clean up the old backup asynchronously — not critical. + // Clean up the old backup. Done synchronously: nvy is a short-lived CLI, so a + // background goroutine would be killed when the process exits before it runs, + // leaking orphaned ".old." trees under ~/.nvy/runtimes/. A failure here + // is non-fatal — the install already succeeded — so the error is swept later by + // SweepStale rather than surfaced. if oldBackup != "" { - go func() { _ = os.RemoveAll(oldBackup) }() + _ = os.RemoveAll(oldBackup) } return nil } @@ -144,6 +155,62 @@ func InstalledVersions(tool string) ([]string, error) { return versions, nil } +// SweepStale removes leftover junk from interrupted or failed installs: +// - staging dirs under ~/.nvy/tmp older than staleTmpAge +// - orphaned ".old." backup trees under ~/.nvy/runtimes// +// +// It is best-effort: any individual removal error is ignored so a single +// permission problem can't stop nvy from running. Safe to call on every startup. +func SweepStale() { + sweepStaleTmp() + sweepOldBackups() +} + +// sweepStaleTmp removes staging dirs older than staleTmpAge. A dir that is newer +// than the threshold may belong to a concurrent install, so it is left alone. +func sweepStaleTmp() { + tmpRoot := filepath.Join(NvyDir(), "tmp") + entries, err := os.ReadDir(tmpRoot) + if err != nil { + return // tmp dir doesn't exist yet, or isn't readable — nothing to sweep + } + cutoff := time.Now().Add(-staleTmpAge) + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.RemoveAll(filepath.Join(tmpRoot, e.Name())) + } + } +} + +// sweepOldBackups removes leftover ".old." backup trees that a previous +// AtomicInstall failed to clean up. These are always safe to remove: their +// presence means the swap already completed and only cleanup was interrupted. +func sweepOldBackups() { + toolDirs, err := os.ReadDir(RuntimesDir()) + if err != nil { + return + } + for _, td := range toolDirs { + if !td.IsDir() { + continue + } + toolPath := filepath.Join(RuntimesDir(), td.Name()) + versions, err := os.ReadDir(toolPath) + if err != nil { + continue + } + for _, v := range versions { + if strings.Contains(v.Name(), ".old.") { + _ = os.RemoveAll(filepath.Join(toolPath, v.Name())) + } + } + } +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/env/env_test.go b/internal/env/env_test.go index c457de9..09b8483 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "runtime" "testing" + "time" "github.com/trevorphillipscoding/nvy/internal/env" ) @@ -140,3 +141,40 @@ func TestAtomicInstall_Replace(t *testing.T) { t.Error("old file should not exist after replace") } } + +func TestSweepStale(t *testing.T) { + base := t.TempDir() + t.Setenv("NVY_DIR", base) + + // Orphaned ".old.*" backup — always safe to remove. + backup := filepath.Join(base, "runtimes", "go", "1.22.1.old.deadbeef") + // A real installed version — must be preserved. + keep := filepath.Join(base, "runtimes", "go", "1.22.1") + // A stale staging dir (backdated) and a fresh one. + staleTmp := filepath.Join(base, "tmp", "stale") + freshTmp := filepath.Join(base, "tmp", "fresh") + for _, d := range []string{backup, keep, staleTmp, freshTmp} { + if err := os.MkdirAll(d, 0750); err != nil { + t.Fatal(err) + } + } + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(staleTmp, old, old); err != nil { + t.Fatal(err) + } + + env.SweepStale() + + if _, err := os.Stat(backup); !os.IsNotExist(err) { + t.Error("orphaned .old.* backup should have been removed") + } + if _, err := os.Stat(keep); err != nil { + t.Error("installed version should be preserved") + } + if _, err := os.Stat(staleTmp); !os.IsNotExist(err) { + t.Error("stale tmp dir should have been removed") + } + if _, err := os.Stat(freshTmp); err != nil { + t.Error("fresh tmp dir should be preserved") + } +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 28e6122..f44f8fc 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -41,57 +41,112 @@ var httpClient = &http.Client{ }, } +// maxAttempts is the total number of tries (initial + retries) for a transient +// network failure. Downloads happen over arbitrary, sometimes flaky networks, so +// a couple of automatic retries meaningfully improves "works in any instance". +const maxAttempts = 3 + // Download fetches url and writes the response body to destPath. // It streams the download and displays a simple progress indicator. -// Only HTTPS URLs are accepted. +// Only HTTPS URLs are accepted. Transient failures (connection resets, 5xx, 429) +// are retried with exponential backoff; 4xx and other permanent errors fail fast. func Download(url, destPath string) error { if !strings.HasPrefix(url, "https://") { return fmt.Errorf("refusing to download from non-HTTPS URL: %s", url) } + return withRetry("download", func() (bool, error) { + return downloadOnce(url, destPath) + }) +} +// downloadOnce performs a single download attempt. The bool reports whether a +// failure is worth retrying (network/mid-stream errors and 5xx/429 responses). +func downloadOnce(url, destPath string) (retriable bool, err error) { resp, err := httpClient.Get(url) if err != nil { - return fmt.Errorf("GET %s: %w", url, err) + return true, fmt.Errorf("GET %s: %w", url, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) + return retriableStatus(resp.StatusCode), fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) } f, err := os.Create(destPath) if err != nil { - return fmt.Errorf("creating %s: %w", destPath, err) + return false, fmt.Errorf("creating %s: %w", destPath, err) } defer func() { _ = f.Close() }() pw := &progressWriter{total: resp.ContentLength} if _, err := io.Copy(f, io.TeeReader(resp.Body, pw)); err != nil { - return fmt.Errorf("downloading %s: %w", url, err) + return true, fmt.Errorf("downloading %s: %w", url, err) } pw.finish() - return nil + return false, nil } // Bytes performs a GET request and returns the response body as bytes. -// Only HTTPS URLs are accepted. +// Only HTTPS URLs are accepted. Transient failures are retried like Download. func Bytes(url string) ([]byte, error) { if !strings.HasPrefix(url, "https://") { return nil, fmt.Errorf("refusing non-HTTPS URL: %s", url) } + var out []byte + err := withRetry("fetch", func() (bool, error) { + data, retriable, err := bytesOnce(url) + if err != nil { + return retriable, err + } + out = data + return false, nil + }) + return out, err +} +func bytesOnce(url string) (data []byte, retriable bool, err error) { resp, err := httpClient.Get(url) if err != nil { - return nil, fmt.Errorf("GET %s: %w", url, err) + return nil, true, fmt.Errorf("GET %s: %w", url, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) + return nil, retriableStatus(resp.StatusCode), fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) } // Limit to 1 MB to protect against unexpectedly large checksum files. - return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, true, err + } + return b, false, nil +} + +// retriableStatus reports whether an HTTP status code warrants a retry. +// Server-side (5xx) and rate-limit (429) responses are transient; 4xx are not. +func retriableStatus(code int) bool { + return code == http.StatusTooManyRequests || (code >= 500 && code <= 599) +} + +// withRetry runs attempt up to maxAttempts times with exponential backoff. +// attempt returns (retriable, err); it stops early on success or a permanent error. +func withRetry(label string, attempt func() (retriable bool, err error)) error { + var lastErr error + for i := 1; i <= maxAttempts; i++ { + retriable, err := attempt() + if err == nil { + return nil + } + lastErr = err + if !retriable || i == maxAttempts { + break + } + backoff := time.Duration(1<<(i-1)) * time.Second + fmt.Fprintf(os.Stderr, " %s attempt %d/%d failed (%v); retrying in %s\n", label, i, maxAttempts, err, backoff) + time.Sleep(backoff) + } + return lastErr } // VerifySHA256 computes the SHA-256 of the file at path and compares it to expected. @@ -120,6 +175,19 @@ func VerifySHA256(path, expected string) error { return nil } +// stdoutIsTTY reports whether stdout is an interactive terminal. When it isn't +// (CI logs, pipes, files), the carriage-return progress animation turns into +// thousands of junk lines, so we suppress the live updates and print one summary. +var stdoutIsTTY = isTerminal(os.Stdout) + +func isTerminal(f *os.File) bool { + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + // progressWriter tracks bytes written and prints a compact progress line to stdout. type progressWriter struct { total int64 // -1 if unknown (no Content-Length header) @@ -130,6 +198,11 @@ func (pw *progressWriter) Write(p []byte) (int, error) { n := len(p) pw.written += int64(n) + // Only animate on a real terminal; a pipe/file gets a single summary at finish. + if !stdoutIsTTY { + return n, nil + } + mb := float64(pw.written) / (1024 * 1024) if pw.total > 0 { pct := float64(pw.written) / float64(pw.total) * 100 @@ -141,6 +214,11 @@ func (pw *progressWriter) Write(p []byte) (int, error) { } func (pw *progressWriter) finish() { + if !stdoutIsTTY { + // Non-interactive: emit one clean summary line instead of live updates. + fmt.Printf(" downloaded %.1f MB\n", float64(pw.written)/(1024*1024)) + return + } // Move to a new line after the progress output. fmt.Println() } diff --git a/internal/state/lock.go b/internal/state/lock.go new file mode 100644 index 0000000..1d59f75 --- /dev/null +++ b/internal/state/lock.go @@ -0,0 +1,41 @@ +package state + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/trevorphillipscoding/nvy/internal/env" +) + +// lockPath is a dedicated lock file, separate from the JSON data files. It must +// be distinct: the data files are replaced via atomic rename, which would break +// any flock held on the old inode. +func lockPath() string { return filepath.Join(env.StateDir(), ".lock") } + +// withLock runs fn while holding an exclusive, cross-process advisory lock on the +// state directory. This serialises the load → mutate → save cycles in state.go so +// two concurrent `nvy global`/`nvy local` invocations can't read the same map and +// clobber each other's writes (a lost update). +// +// Reads (GetGlobal, LookupShim, …) deliberately do NOT take the lock: writers +// commit via atomic rename, so a lock-free reader always sees a complete old or +// new file. Keeping reads lock-free matters for the hot shim path. +func withLock(fn func() error) error { + if err := os.MkdirAll(env.StateDir(), 0700); err != nil { + return fmt.Errorf("creating state dir: %w", err) + } + f, err := os.OpenFile(lockPath(), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("opening state lock: %w", err) + } + defer func() { _ = f.Close() }() + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return fmt.Errorf("acquiring state lock: %w", err) + } + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() + + return fn() +} diff --git a/internal/state/state.go b/internal/state/state.go index 9c80bb9..982fdfb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -21,18 +21,27 @@ type globalState struct { // SetGlobal writes tool → version into global.json. func SetGlobal(tool, version string) error { - s, err := load() - if err != nil { - return err - } - s.Versions[tool] = version - return save(s) + return withLock(func() error { + s, err := load() + if err != nil { + return err + } + s.Versions[tool] = version + return save(s) + }) } // GetGlobal returns the currently active global version for tool, and whether one is set. +// +// A missing state file legitimately means "nothing set" and returns ("", false). +// A corrupt/unreadable file is different: silently returning ("", false) would make +// a broken install look like an unconfigured one ("go: command not found" with no +// hint why). We can't change the signature without churning every caller, so we +// warn to stderr and still report "not set". func GetGlobal(tool string) (version string, ok bool) { s, err := load() if err != nil { + warnCorruptState(env.GlobalStatePath(), err) return "", false } v, ok := s.Versions[tool] @@ -55,12 +64,14 @@ func AllGlobals() (map[string]string, error) { // DeleteGlobal removes tool from global.json. It is a no-op if tool has no entry. func DeleteGlobal(tool string) error { - s, err := load() - if err != nil { - return err - } - delete(s.Versions, tool) - return save(s) + return withLock(func() error { + s, err := load() + if err != nil { + return err + } + delete(s.Versions, tool) + return save(s) + }) } // ── Owner tracking ─────────────────────────────────────────────────────────── @@ -72,49 +83,91 @@ type ownerState struct { Owners map[string]string `json:"owners"` } +// ShimConflict records a binary whose ownership was reassigned from one tool to +// another during RegisterShims — i.e. two runtimes ship a binary with the same +// name (e.g. both provide "python"). The binary now resolves to the newly +// registered tool, shadowing the previous owner. Callers surface these so the +// takeover is visible instead of silent. +type ShimConflict struct { + Binary string // the colliding binary name, e.g. "python" + PreviousOwner string // the tool that owned it before, e.g. "python" +} + // RegisterShims records that each binary in binaries belongs to tool. // Existing entries for other tools are preserved; only this tool's entries are updated. -func RegisterShims(tool string, binaries []string) error { - s, err := loadOwners() +// +// It returns any conflicts: binaries that were previously owned by a *different* +// tool and have now been re-pointed to tool. Re-registering a binary the same +// tool already owned is not a conflict. +func RegisterShims(tool string, binaries []string) ([]ShimConflict, error) { + var conflicts []ShimConflict + err := withLock(func() error { + s, err := loadOwners() + if err != nil { + return err + } + // Recompute from scratch each attempt so a lock retry can't accumulate + // duplicate conflict entries. + conflicts = conflicts[:0] + for _, b := range binaries { + if prev, ok := s.Owners[b]; ok && prev != tool { + conflicts = append(conflicts, ShimConflict{Binary: b, PreviousOwner: prev}) + } + s.Owners[b] = tool + } + return saveOwners(s) + }) if err != nil { - return err - } - for _, b := range binaries { - s.Owners[b] = tool + return nil, err } - return saveOwners(s) + return conflicts, nil } // UnregisterShims removes all binaries owned by tool from owners.json. // Returns the list of binary names removed. func UnregisterShims(tool string) ([]string, error) { - s, err := loadOwners() - if err != nil { - return nil, err - } var removed []string - for bin, owner := range s.Owners { - if owner == tool { - removed = append(removed, bin) - delete(s.Owners, bin) + err := withLock(func() error { + s, err := loadOwners() + if err != nil { + return err } + for bin, owner := range s.Owners { + if owner == tool { + removed = append(removed, bin) + delete(s.Owners, bin) + } + } + if len(removed) == 0 { + return nil + } + return saveOwners(s) + }) + if err != nil { + return nil, err } - if len(removed) == 0 { - return nil, nil - } - return removed, saveOwners(s) + return removed, nil } // LookupShim returns the tool name that owns binary, e.g. "npm" → "node". +// Like GetGlobal, a corrupt owners.json is surfaced to stderr rather than silently +// masquerading as "no such shim". func LookupShim(binary string) (tool string, ok bool) { s, err := loadOwners() if err != nil { + warnCorruptState(ownersPath(), err) return "", false } t, ok := s.Owners[binary] return t, ok } +// warnCorruptState prints a one-line diagnostic to stderr when a state file exists +// but can't be read or parsed. Best-effort: it never alters control flow. +func warnCorruptState(path string, err error) { + fmt.Fprintf(os.Stderr, "nvy: warning: state file %s is unreadable (%v); treating as unset\n", path, err) +} + func ownersPath() string { return env.StateDir() + "/owners.json" } func loadOwners() (*ownerState, error) { diff --git a/internal/state/state_test.go b/internal/state/state_test.go index f352772..f1a22ef 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1,13 +1,44 @@ package state import ( + "fmt" "os" "slices" + "sync" "testing" "github.com/trevorphillipscoding/nvy/internal/env" ) +// TestSetGlobal_ConcurrentNoLostUpdates spawns many goroutines that each write a +// distinct tool. Without the state lock the load→mutate→save cycles interleave and +// writes are lost; with it, every entry must survive. +func TestSetGlobal_ConcurrentNoLostUpdates(t *testing.T) { + t.Setenv("NVY_DIR", t.TempDir()) + + const n = 20 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + tool := fmt.Sprintf("tool%02d", i) + if err := SetGlobal(tool, "1.0.0"); err != nil { + t.Errorf("SetGlobal(%s): %v", tool, err) + } + }(i) + } + wg.Wait() + + all, err := AllGlobals() + if err != nil { + t.Fatalf("AllGlobals: %v", err) + } + if len(all) != n { + t.Fatalf("expected %d tools after concurrent writes, got %d: %v", n, len(all), all) + } +} + func TestSetAndGetGlobal(t *testing.T) { // Redirect ~/.nvy to a temp dir so tests don't touch real state. tmp := t.TempDir() @@ -131,7 +162,7 @@ func TestDeleteGlobal_Noop(t *testing.T) { func TestRegisterAndLookupShims(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - if err := RegisterShims("go", []string{"go", "gofmt"}); err != nil { + if _, err := RegisterShims("go", []string{"go", "gofmt"}); err != nil { t.Fatalf("RegisterShims: %v", err) } @@ -154,10 +185,10 @@ func TestRegisterAndLookupShims(t *testing.T) { func TestUnregisterShims(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - if err := RegisterShims("go", []string{"go", "gofmt"}); err != nil { + if _, err := RegisterShims("go", []string{"go", "gofmt"}); err != nil { t.Fatal(err) } - if err := RegisterShims("node", []string{"node", "npm"}); err != nil { + if _, err := RegisterShims("node", []string{"node", "npm"}); err != nil { t.Fatal(err) } @@ -186,6 +217,41 @@ func TestUnregisterShims(t *testing.T) { } } +func TestRegisterShims_ReportsCollisions(t *testing.T) { + t.Setenv("NVY_DIR", t.TempDir()) + + // First tool claims "shared" and "onlyA". + if _, err := RegisterShims("toolA", []string{"shared", "onlyA"}); err != nil { + t.Fatal(err) + } + + // Second tool also ships "shared" — that's a takeover — plus its own "onlyB". + conflicts, err := RegisterShims("toolB", []string{"shared", "onlyB"}) + if err != nil { + t.Fatalf("RegisterShims: %v", err) + } + if len(conflicts) != 1 { + t.Fatalf("expected exactly 1 conflict, got %d: %v", len(conflicts), conflicts) + } + if conflicts[0].Binary != "shared" || conflicts[0].PreviousOwner != "toolA" { + t.Errorf("conflict = %+v; want {Binary:shared PreviousOwner:toolA}", conflicts[0]) + } + + // After the takeover, "shared" resolves to toolB. + if owner, _ := LookupShim("shared"); owner != "toolB" { + t.Errorf("LookupShim(shared) = %q; want toolB", owner) + } + + // Re-registering the same binaries for the same tool is not a collision. + conflicts, err = RegisterShims("toolB", []string{"shared", "onlyB"}) + if err != nil { + t.Fatal(err) + } + if len(conflicts) != 0 { + t.Errorf("re-registering same tool should report no conflicts, got %v", conflicts) + } +} + func TestUnregisterShims_Noop(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) diff --git a/plugins/all/all.go b/plugins/all/all.go index d6ba0b1..986de69 100644 --- a/plugins/all/all.go +++ b/plugins/all/all.go @@ -10,5 +10,6 @@ package all import ( _ "github.com/trevorphillipscoding/nvy/plugins/golang" // register Go plugin _ "github.com/trevorphillipscoding/nvy/plugins/node" // register Node.js plugin + _ "github.com/trevorphillipscoding/nvy/plugins/php" // register PHP plugin _ "github.com/trevorphillipscoding/nvy/plugins/python" // register Python plugin ) diff --git a/plugins/golang/golang.go b/plugins/golang/golang.go index 766743f..fe5fa02 100644 --- a/plugins/golang/golang.go +++ b/plugins/golang/golang.go @@ -6,7 +6,6 @@ package golang import ( "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -79,7 +78,7 @@ func fetchStableGoVersions() ([]string, error) { if err != nil { return nil, fmt.Errorf("go plugin: fetching releases: %w", err) } - body, err := io.ReadAll(resp.Body) + body, err := plugins.ReadAPIBody(resp.Body) _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("go plugin: reading releases response: %w", err) diff --git a/plugins/httputil.go b/plugins/httputil.go new file mode 100644 index 0000000..a5c5ec0 --- /dev/null +++ b/plugins/httputil.go @@ -0,0 +1,27 @@ +package plugins + +import ( + "fmt" + "io" +) + +// MaxAPIResponse caps how many bytes a plugin will buffer from a releases API. +// The largest real payload — Go's `?mode=json&include=all` index — is a few MB; +// 32 MB leaves generous headroom while bounding memory against a runaway or +// hostile response. Plugins can't use fetch.Bytes (its 1 MB cap is sized for +// checksum files), so they share this helper instead of an unbounded io.ReadAll. +const MaxAPIResponse = 32 << 20 + +// ReadAPIBody reads r up to MaxAPIResponse bytes, returning an error if the +// source exceeds that cap rather than silently truncating (which would corrupt +// the JSON/feed parse downstream). +func ReadAPIBody(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, MaxAPIResponse+1)) + if err != nil { + return nil, err + } + if len(data) > MaxAPIResponse { + return nil, fmt.Errorf("API response exceeds %d bytes — refusing to buffer", MaxAPIResponse) + } + return data, nil +} diff --git a/plugins/node/node.go b/plugins/node/node.go index 6fe922a..014bc5b 100644 --- a/plugins/node/node.go +++ b/plugins/node/node.go @@ -6,7 +6,6 @@ package node import ( "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -80,7 +79,7 @@ func fetchNodeVersions() ([]string, error) { if err != nil { return nil, fmt.Errorf("node plugin: fetching releases: %w", err) } - body, err := io.ReadAll(resp.Body) + body, err := plugins.ReadAPIBody(resp.Body) _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("node plugin: reading releases response: %w", err) diff --git a/plugins/php/php.go b/plugins/php/php.go new file mode 100644 index 0000000..4c3ac91 --- /dev/null +++ b/plugins/php/php.go @@ -0,0 +1,165 @@ +// Package php implements the nvy plugin for PHP. +// +// PHP is unusual among the built-in runtimes: php.net ships source tarballs +// only (which would need a full build toolchain), while the static-php-cli +// project (https://dl.static-php.dev) publishes ready-to-run, self-contained +// static `php` binaries — but with no checksums of any kind alongside them. +// +// This plugin therefore discovers versions from static-php-cli's directory +// listing at runtime and downloads the matching CLI binary. Because upstream +// publishes no checksum, the DownloadSpec sets SkipChecksum: install then relies +// on the fetch package's strict HTTPS/TLS enforcement rather than content +// pinning (and prints a clear notice). +package php + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "time" + + "github.com/trevorphillipscoding/nvy/internal/semver" + "github.com/trevorphillipscoding/nvy/plugins" +) + +const distBase = "https://dl.static-php.dev/static-php-cli/common" + +// listingURL is a var so tests can override it with an httptest server. +// ?format=json makes the directory index return a machine-readable file list. +var listingURL = distBase + "/?format=json" + +// assetPattern matches a CLI tarball filename, capturing version, os, and arch. +// Example: php-8.3.32-cli-linux-x86_64.tar.gz +var assetPattern = regexp.MustCompile(`^php-(\d+\.\d+\.\d+)-cli-(linux|macos)-(x86_64|aarch64)\.tar\.gz$`) + +func init() { + plugins.Register(New()) +} + +type phpPlugin struct{} + +// New returns the PHP plugin. Called by init(); exposed for testing. +func New() plugins.Plugin { return &phpPlugin{} } + +func (p *phpPlugin) Name() string { return "php" } + +func (p *phpPlugin) Aliases() []string { return nil } + +// AvailableVersions lists every static-php-cli CLI build for the given platform. +// Ordering is handled centrally by internal/semver, so the collection order here +// doesn't matter. +func (p *phpPlugin) AvailableVersions(goos, goarch string) ([]string, error) { + token, err := platformToken(goos, goarch) + if err != nil { + return nil, err + } + + names, err := fetchListing() + if err != nil { + return nil, err + } + + seen := map[string]bool{} + versions := make([]string, 0) + for _, name := range names { + m := assetPattern.FindStringSubmatch(name) + if m == nil || m[2]+"-"+m[3] != token { + continue + } + if _, err := semver.ParseVersion(m[1]); err != nil { + continue + } + if !seen[m[1]] { + seen[m[1]] = true + versions = append(versions, m[1]) + } + } + if len(versions) == 0 { + return nil, fmt.Errorf("php plugin: no versions found for %s/%s", goos, goarch) + } + return versions, nil +} + +// Resolve builds the download spec for a static-php-cli CLI binary. +// +// Naming convention: +// +// php--cli--.tar.gz ← archive is a bare `php` executable +// +// Example: php-8.3.32-cli-linux-x86_64.tar.gz +func (p *phpPlugin) Resolve(version, goos, goarch string) (*plugins.DownloadSpec, error) { + token, err := platformToken(goos, goarch) + if err != nil { + return nil, err + } + + v, err := semver.ParseVersion(version) + if err != nil { + return nil, fmt.Errorf("php plugin: %w", err) + } + + filename := fmt.Sprintf("php-%s-cli-%s.tar.gz", v, token) + + return &plugins.DownloadSpec{ + URL: fmt.Sprintf("%s/%s", distBase, filename), + SkipChecksum: true, // static-php-cli publishes no checksum; integrity via HTTPS/TLS + ExtractSubdir: "bin", // archive is a bare `php` binary — place it under bin/ + }, nil +} + +// fetchListing returns the file names in static-php-cli's common directory. +func fetchListing() ([]string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(listingURL) + if err != nil { + return nil, fmt.Errorf("php plugin: fetching version listing: %w", err) + } + body, err := plugins.ReadAPIBody(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("php plugin: reading version listing: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("php plugin: version listing returned %s", resp.Status) + } + + var entries []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("php plugin: parsing version listing JSON: %w", err) + } + + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name + } + return names, nil +} + +// platformToken maps GOOS/GOARCH to the "-" token static-php-cli uses +// in its filenames (e.g. linux/amd64 → "linux-x86_64"). +func platformToken(goos, goarch string) (string, error) { + var os string + switch goos { + case "linux": + os = "linux" + case "darwin": + os = "macos" + default: + return "", fmt.Errorf("php plugin: unsupported OS %q (supported: linux, darwin)", goos) + } + + var arch string + switch goarch { + case "amd64": + arch = "x86_64" + case "arm64": + arch = "aarch64" + default: + return "", fmt.Errorf("php plugin: unsupported architecture %q (supported: amd64, arm64)", goarch) + } + + return os + "-" + arch, nil +} diff --git a/plugins/php/php_test.go b/plugins/php/php_test.go new file mode 100644 index 0000000..f99d0d4 --- /dev/null +++ b/plugins/php/php_test.go @@ -0,0 +1,191 @@ +package php + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/trevorphillipscoding/nvy/internal/semver" +) + +// listingServer starts an httptest server that returns names as a static-php-cli +// directory listing, and points listingURL at it for the duration of the test. +func listingServer(t *testing.T, names []string) { + t.Helper() + type entry struct { + Name string `json:"name"` + } + entries := make([]entry, len(names)) + for i, n := range names { + entries[i] = entry{Name: n} + } + body, _ := json.Marshal(entries) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + + orig := listingURL + listingURL = srv.URL + t.Cleanup(func() { listingURL = orig }) +} + +func TestName(t *testing.T) { + if got := New().Name(); got != "php" { + t.Errorf("Name() = %q; want php", got) + } +} + +func TestPlatformToken(t *testing.T) { + cases := []struct { + goos, goarch string + want string + wantErr bool + }{ + {"linux", "amd64", "linux-x86_64", false}, + {"linux", "arm64", "linux-aarch64", false}, + {"darwin", "amd64", "macos-x86_64", false}, + {"darwin", "arm64", "macos-aarch64", false}, + {"windows", "amd64", "", true}, + {"linux", "mips", "", true}, + } + for _, c := range cases { + got, err := platformToken(c.goos, c.goarch) + if c.wantErr { + if err == nil { + t.Errorf("platformToken(%q, %q): expected error, got nil", c.goos, c.goarch) + } + continue + } + if err != nil { + t.Errorf("platformToken(%q, %q): unexpected error: %v", c.goos, c.goarch, err) + continue + } + if got != c.want { + t.Errorf("platformToken(%q, %q) = %q; want %q", c.goos, c.goarch, got, c.want) + } + } +} + +func TestResolve(t *testing.T) { + p := New() + spec, err := p.Resolve("8.3.32", "linux", "amd64") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !strings.HasPrefix(spec.URL, "https://") { + t.Errorf("URL must be HTTPS, got %q", spec.URL) + } + if !strings.Contains(spec.URL, "php-8.3.32-cli-linux-x86_64.tar.gz") { + t.Errorf("URL should contain the platform filename, got %q", spec.URL) + } + if !spec.SkipChecksum { + t.Error("SkipChecksum should be true (upstream publishes no checksum)") + } + if spec.SHA256 != "" || spec.ChecksumURL != "" { + t.Errorf("no checksum should be set; got SHA256=%q ChecksumURL=%q", spec.SHA256, spec.ChecksumURL) + } + if spec.ExtractSubdir != "bin" { + t.Errorf("ExtractSubdir = %q; want bin (archive is a bare binary)", spec.ExtractSubdir) + } + if spec.StripComponents != 0 { + t.Errorf("StripComponents = %d; want 0", spec.StripComponents) + } +} + +func TestResolve_PartialVersionRejected(t *testing.T) { + // Resolve always receives a full version; a bare "8.3" is invalid here. + p := New() + if _, err := p.Resolve("8.3", "linux", "amd64"); err == nil { + t.Error("expected error for non-exact version 8.3, got nil") + } +} + +func TestResolve_UnsupportedPlatform(t *testing.T) { + p := New() + if _, err := p.Resolve("8.3.32", "windows", "amd64"); err == nil { + t.Error("expected error for unsupported OS, got nil") + } +} + +func TestAvailableVersions_FiltersPlatformAndKind(t *testing.T) { + listingServer(t, []string{ + "README.txt", + "php-8.0.30-cli-linux-x86_64.tar.gz", + "php-8.1.34-cli-linux-x86_64.tar.gz", + "php-8.2.32-cli-linux-x86_64.tar.gz", + "php-8.3.32-cli-linux-x86_64.tar.gz", + "php-8.3.32-cli-macos-aarch64.tar.gz", // wrong platform → excluded + "php-8.5.8-cli-linux-x86_64.tar.gz", + "php-8.3.32-fpm-linux-x86_64.tar.gz", // fpm, not cli → excluded + }) + + p := New() + versions, err := p.AvailableVersions("linux", "amd64") + if err != nil { + t.Fatalf("AvailableVersions: %v", err) + } + + got := map[string]bool{} + for _, v := range versions { + got[v] = true + } + // All CLI builds for linux/amd64 are listed, including old lines. + for _, want := range []string{"8.0.30", "8.1.34", "8.2.32", "8.3.32", "8.5.8"} { + if !got[want] { + t.Errorf("expected %s in available versions, got %v", want, versions) + } + } + if len(versions) != 5 { + t.Errorf("got %d versions; want 5 (linux/amd64 cli builds): %v", len(versions), versions) + } + + // A partial request resolves to the newest matching patch. + resolved, err := semver.Resolve("8.3", versions) + if err != nil { + t.Fatalf("Resolve 8.3: %v", err) + } + if resolved != "8.3.32" { + t.Errorf("Resolve(8.3) = %q; want 8.3.32", resolved) + } +} + +func TestAvailableVersions_UnsupportedPlatform(t *testing.T) { + p := New() + if _, err := p.AvailableVersions("windows", "amd64"); err == nil { + t.Error("expected error for unsupported platform, got nil") + } +} + +func TestAvailableVersions_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + orig := listingURL + listingURL = srv.URL + defer func() { listingURL = orig }() + + p := New() + if _, err := p.AvailableVersions("linux", "amd64"); err == nil { + t.Error("expected error on server 500, got nil") + } +} + +func TestAvailableVersions_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + orig := listingURL + listingURL = srv.URL + defer func() { listingURL = orig }() + + p := New() + if _, err := p.AvailableVersions("linux", "amd64"); err == nil { + t.Error("expected error on bad JSON, got nil") + } +} diff --git a/plugins/plugin.go b/plugins/plugin.go index 2c7b31a..dc86326 100644 --- a/plugins/plugin.go +++ b/plugins/plugin.go @@ -24,6 +24,15 @@ type DownloadSpec struct { // Leave empty when ChecksumURL points directly to a single hex hash. ChecksumFilename string + // SkipChecksum disables SHA-256 verification for this download. + // + // Set it ONLY when the upstream distributor publishes no checksum of any kind + // (e.g. static-php-cli). Integrity then rests entirely on the fetch package's + // strict HTTPS/TLS enforcement — there is no content pinning — so install + // prints a clear notice. Never set this alongside SHA256/ChecksumURL; if a + // checksum is available, use it instead. + SkipChecksum bool + // StripComponents strips this many leading path components during extraction, // equivalent to tar(1)'s --strip-components flag. // @@ -31,6 +40,16 @@ type DownloadSpec struct { // go1.22.1.linux-amd64.tar.gz → go/bin/go (strip 1) // node-v20.11.1-linux-x64.tar.gz → node-v.../bin/node (strip 1) StripComponents int + + // ExtractSubdir places the archive contents under this subdirectory of the + // install root instead of at its top level. Leave empty for archives that + // already ship a bin/ layout (Go, Node, Python). + // + // It exists for runtimes distributed as a bare executable with no directory + // structure: the shim mechanism only discovers binaries under /bin, + // so such a payload must be redirected there. + // php-8.3.32-cli-linux-x86_64.tar.gz → php (bare binary) → bin/php (subdir "bin") + ExtractSubdir string } // Plugin is the interface every language runtime installer must satisfy. diff --git a/plugins/plugin_test.go b/plugins/plugin_test.go index 73ad4ed..c862b97 100644 --- a/plugins/plugin_test.go +++ b/plugins/plugin_test.go @@ -8,6 +8,7 @@ import ( _ "github.com/trevorphillipscoding/nvy/plugins/all" // register all built-in plugins "github.com/trevorphillipscoding/nvy/plugins/golang" "github.com/trevorphillipscoding/nvy/plugins/node" + "github.com/trevorphillipscoding/nvy/plugins/php" "github.com/trevorphillipscoding/nvy/plugins/python" ) @@ -185,6 +186,34 @@ func TestPythonPlugin_ExplicitTagNoResolvedVersion(t *testing.T) { } } +func TestPHPPlugin_Resolve(t *testing.T) { + p := php.New() + + if p.Name() != "php" { + t.Errorf("Name() = %q; want php", p.Name()) + } + + spec, err := p.Resolve("8.3.32", "darwin", "arm64") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !strings.HasPrefix(spec.URL, "https://") { + t.Errorf("URL must be HTTPS, got %q", spec.URL) + } + if !strings.Contains(spec.URL, "macos-aarch64") { + t.Errorf("URL should contain the darwin/arm64 token, got %q", spec.URL) + } + // static-php-cli publishes no checksum, so verification is skipped in favour + // of the fetch package's strict HTTPS/TLS enforcement. + if !spec.SkipChecksum { + t.Error("SkipChecksum must be set for PHP (no checksum published upstream)") + } + // The tarball is a bare `php` binary; it must be redirected under bin/. + if spec.ExtractSubdir != "bin" { + t.Errorf("ExtractSubdir = %q; want bin", spec.ExtractSubdir) + } +} + func TestRegistry_AliasResolution(t *testing.T) { cases := []struct { alias string @@ -231,7 +260,7 @@ func TestRegistry_All(t *testing.T) { for _, p := range all { names[p.Name()] = true } - for _, want := range []string{"go", "node", "python"} { + for _, want := range []string{"go", "node", "php", "python"} { if !names[want] { t.Errorf("All() missing plugin %q", want) } diff --git a/plugins/python/python.go b/plugins/python/python.go index 00c9739..fe1b572 100644 --- a/plugins/python/python.go +++ b/plugins/python/python.go @@ -7,7 +7,6 @@ package python import ( "encoding/json" "fmt" - "io" "net/http" "regexp" "strings" @@ -134,7 +133,7 @@ func listAvailableVersions(triple string) ([]string, error) { if err != nil { return nil, fmt.Errorf("python plugin: fetching releases: %w", err) } - body, err := io.ReadAll(resp.Body) + body, err := plugins.ReadAPIBody(resp.Body) _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("python plugin: reading releases response: %w", err) @@ -187,7 +186,7 @@ func findReleaseTag(pyVersion, triple string) (string, error) { if err != nil { return "", fmt.Errorf("python plugin: fetching release feed: %w", err) } - body, err := io.ReadAll(resp.Body) + body, err := plugins.ReadAPIBody(resp.Body) _ = resp.Body.Close() if err != nil { return "", fmt.Errorf("python plugin: reading release feed: %w", err) diff --git a/scripts/local-test.sh b/scripts/local-test.sh new file mode 100755 index 0000000..f68326d --- /dev/null +++ b/scripts/local-test.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# +# local-test.sh — end-to-end smoke test for the nvy CLI. +# +# What it does: +# 1. Static checks : go build, go vet, go test -race, golangci-lint (if present) +# 2. Offline E2E : drives every CLI command against an ISOLATED sandbox +# (NVY_DIR points at a temp dir, never your real ~/.nvy). +# Uses fabricated "fake" runtimes so it needs no network +# and is fully deterministic. +# 3. --full (opt-in): additionally performs a REAL network install of Go, +# activates it, execs it, and uninstalls it. +# +# Your real ~/.nvy is never read or modified. The sandbox is removed on exit. +# +# Usage: +# scripts/local-test.sh # static checks + offline E2E +# scripts/local-test.sh --full # also run the real network install test +# scripts/local-test.sh --no-lint # skip golangci-lint +# NVY_TEST_GO=/path/to/go scripts/local-test.sh # force a specific toolchain +# +set -uo pipefail + +# ── Options ─────────────────────────────────────────────────────────────────── +RUN_FULL=0 +RUN_LINT=1 +for arg in "$@"; do + case "$arg" in + --full) RUN_FULL=1 ;; + --no-lint) RUN_LINT=0 ;; + -h | --help) + sed -n '2,30p' "$0" | sed 's/^#//' + exit 0 + ;; + *) + echo "unknown option: $arg (try --help)" >&2 + exit 2 + ;; + esac +done + +# ── Locate the repo root (script lives in /scripts) ───────────────────── +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO" + +# ── Colours (only when writing to a terminal) ───────────────────────────────── +if [ -t 1 ]; then + RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; BOLD=$'\033[1m'; RESET=$'\033[0m' +else + RED=; GREEN=; YELLOW=; BOLD=; RESET= +fi + +PASSED=0 +FAILED=0 + +section() { printf "\n${BOLD}== %s ==${RESET}\n" "$1"; } +pass() { printf " ${GREEN}✓${RESET} %s\n" "$1"; PASSED=$((PASSED + 1)); } +fail() { + printf " ${RED}✗${RESET} %s\n" "$1" + FAILED=$((FAILED + 1)) + if [ -s "$OUT" ]; then sed 's/^/ | /' "$OUT"; fi +} +skip() { printf " ${YELLOW}∅${RESET} %s\n" "$1"; } + +# ── Pick a Go toolchain that satisfies go.mod (needs >= 1.25) ────────────────── +# Preference: $NVY_TEST_GO → newest managed go under ~/.nvy → system go. +go_ok() { # arg: path to a go binary. Returns 0 if version >= 1.25. + local v maj min rest + v="$("$1" version 2>/dev/null | awk '{print $3}' | sed 's/^go//')" || return 1 + [ -n "$v" ] || return 1 + maj="${v%%.*}"; rest="${v#*.}"; min="${rest%%.*}" + [ "$maj" -gt 1 ] 2>/dev/null && return 0 + [ "$maj" -eq 1 ] 2>/dev/null && [ "$min" -ge 25 ] 2>/dev/null && return 0 + return 1 +} + +pick_go() { + if [ -n "${NVY_TEST_GO:-}" ] && go_ok "$NVY_TEST_GO"; then echo "$NVY_TEST_GO"; return 0; fi + local d + for d in $(ls -d "$HOME"/.nvy/runtimes/go/*/ 2>/dev/null | sort -Vr); do + if go_ok "${d}bin/go"; then echo "${d}bin/go"; return 0; fi + done + local sys; sys="$(command -v go 2>/dev/null || true)" + if [ -n "$sys" ] && go_ok "$sys"; then echo "$sys"; return 0; fi + return 1 +} + +GO="$(pick_go)" || { + echo "${RED}error:${RESET} no Go toolchain >= 1.25 found." >&2 + echo " go.mod requires it. Set NVY_TEST_GO=/path/to/go or install one under ~/.nvy." >&2 + exit 1 +} +export GOTOOLCHAIN=local +export PATH="$(dirname "$GO"):$PATH" # so golangci-lint uses the same toolchain +echo "${BOLD}nvy local test${RESET} (go: $("$GO" version | awk '{print $3}'), repo: $REPO)" + +# ── Isolated sandbox — the whole point: never touch the real ~/.nvy ─────────── +SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/nvy-localtest.XXXXXX")" +# Canonicalise: a trailing slash in $TMPDIR can leave a "//" in the path, which +# the binary's filepath.Join collapses — normalise here so path assertions match. +SANDBOX="$(cd "$SANDBOX" && pwd)" +export NVY_DIR="$SANDBOX/root" +# The binary's base name MUST be "nvy": when invoked under any other name the +# program acts as a shim (that's how ~/.nvy/shims/go dispatches). Keep it in its +# own dir so the name is exactly "nvy". +NVY="$SANDBOX/bin/nvy" +mkdir -p "$SANDBOX/bin" +OUT="$SANDBOX/.last-output" +PROJECT="$SANDBOX/project" # clean cwd for version-file-sensitive commands +mkdir -p "$PROJECT" + +cleanup() { rm -rf "$SANDBOX"; } +trap cleanup EXIT + +# ── Assertion helpers ───────────────────────────────────────────────────────── +# Each runs a command capturing combined output to $OUT, then checks a condition. +run() { "$@" >"$OUT" 2>&1; } # preserves exit code + +assert_ok() { # desc, cmd... + local desc="$1"; shift + if run "$@"; then pass "$desc"; else fail "$desc [exit $?]"; fi +} +assert_fail() { # desc, cmd... (expects NON-zero exit) + local desc="$1"; shift + if run "$@"; then fail "$desc [expected failure but succeeded]"; else pass "$desc"; fi +} +assert_contains() { # desc, needle, cmd... + local desc="$1" needle="$2"; shift 2 + run "$@" || true + if grep -qF -- "$needle" "$OUT"; then pass "$desc"; else fail "$desc [output missing: '$needle']"; fi +} +assert_equals() { # desc, expected, cmd... + local desc="$1" want="$2"; shift 2 + local got; got="$("$@" 2>/dev/null)" + if [ "$got" = "$want" ]; then pass "$desc"; else printf '%s\n' "$got" >"$OUT"; fail "$desc [want '$want']"; fi +} + +# fake_runtime [bin2...] — fabricate an installed runtime +# under the sandbox so state/shim commands can be tested with no network. Each +# fake binary is a shell script that echoes its own identity and args. +fake_runtime() { + local tool="$1" version="$2"; shift 2 + local bindir="$NVY_DIR/runtimes/$tool/$version/bin" b + mkdir -p "$bindir" + for b in "$@"; do + printf '#!/bin/sh\necho "fake-%s %s args: $*"\n' "$b" "$version" >"$bindir/$b" + chmod +x "$bindir/$b" + done +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Phase 1 — Static checks +# ══════════════════════════════════════════════════════════════════════════════ +section "Static checks" +assert_ok "go build ./..." "$GO" build ./... +assert_ok "go vet ./..." "$GO" vet ./... +assert_ok "go test -race ./..." "$GO" test -race ./... +if [ "$RUN_LINT" -eq 1 ]; then + if command -v golangci-lint >/dev/null 2>&1; then + assert_ok "golangci-lint run" golangci-lint run ./... + else + skip "golangci-lint not installed — skipping (install it or pass --no-lint)" + fi +fi + +# Build the binary we'll drive for the CLI tests. +if "$GO" build -o "$NVY" . ; then + pass "built test binary" +else + fail "building test binary — aborting CLI tests" + printf "\nResults: ${GREEN}%d passed${RESET}, ${RED}%d failed${RESET}\n" "$PASSED" "$FAILED" + exit 1 +fi + +# From here on, run inside the clean project dir so a stray .-version file +# in the repo can't leak into resolution. +cd "$PROJECT" + +# ══════════════════════════════════════════════════════════════════════════════ +# Phase 2 — Offline end-to-end CLI (fabricated runtimes, no network) +# ══════════════════════════════════════════════════════════════════════════════ +section "CLI: empty state" +assert_contains "current: reports nothing configured" "no versions configured" "$NVY" current +assert_contains "list: reports none installed" "none installed" "$NVY" list +assert_fail "which: errors when no shim exists" "$NVY" which go + +section "CLI: help & metadata" +assert_contains "help lists new commands (which)" "which" "$NVY" --help +assert_contains "help lists new commands (current)" "current" "$NVY" --help +assert_contains "help lists new commands (exec)" "exec" "$NVY" --help +assert_contains "help lists new commands (versions)" "versions" "$NVY" --help +assert_ok "version flag works" "$NVY" --version + +section "CLI: activate a (fake) runtime" +fake_runtime go 9.9.9 go gofmt +assert_contains "global: activates go 9.9.9" "now using go 9.9.9" "$NVY" global go 9.9.9 +assert_contains "list: shows go as global" "global" "$NVY" list +assert_contains "current: shows resolved version" "9.9.9" "$NVY" current go +assert_contains "current: shows global source" "global" "$NVY" current go +assert_equals "which: prints exact install path" "$NVY_DIR/runtimes/go/9.9.9/bin/go" "$NVY" which go + +section "CLI: exec (resolves + runs the real binary)" +assert_contains "exec go: runs the resolved binary" "fake-go 9.9.9" "$NVY" exec go version +assert_contains "exec passes args through" "args: build ./..." "$NVY" exec go build ./... +assert_contains "exec passes flags through" "args: --version" "$NVY" exec go --version + +section "CLI: local pin overrides global" +assert_ok "local: writes .go-version" "$NVY" local go 9.9.9 +assert_contains "current: source flips to local" "local" "$NVY" current go +rm -f "$PROJECT/.go-version" + +section "CLI: cross-tool shim collision warning" +# node's fake runtime also ships a 'gofmt' — activating it must steal 'gofmt' +# from go and warn about the takeover instead of doing it silently. The takeover +# only happens on the FIRST activation, so inspect a single run's output. +fake_runtime node 9.9.9 node gofmt +run "$NVY" global node 9.9.9 || true +if grep -qF "now resolves to node" "$OUT"; then pass "global node: warns 'gofmt' now resolves to node"; else fail "collision warning missing"; fi +if grep -qF "was: go" "$OUT"; then pass "collision warning names the previous owner (go)"; else fail "collision warning missing 'was: go'"; fi + +section "CLI: uninstall cleans up" +assert_contains "uninstall: removes go 9.9.9" "uninstalled go 9.9.9" "$NVY" uninstall go 9.9.9 +assert_fail "which go: fails after uninstall" "$NVY" which go + +section "CLI: error handling" +assert_fail "install with no version" "$NVY" install go +assert_fail "global with unknown runtime" "$NVY" global definitelynotareal 1.0.0 +assert_fail "exec with no binary" "$NVY" exec +assert_fail "versions with unknown tool" "$NVY" versions definitelynotareal + +# ══════════════════════════════════════════════════════════════════════════════ +# Phase 3 — Real network install (opt-in via --full) +# ══════════════════════════════════════════════════════════════════════════════ +if [ "$RUN_FULL" -eq 1 ]; then + section "Full: real network install of Go" + # Discover the newest installable Go version from the live release index. + TOP="$("$NVY" versions go 2>/dev/null | awk 'NR>1 {gsub(/[* ]/, ""); if ($0 != "") {print; exit}}')" + if [ -z "$TOP" ]; then + fail "versions go: could not fetch any version (network?)" + else + pass "versions go: newest available is $TOP" + assert_contains "install go $TOP (download+verify+extract)" "installed go $TOP" "$NVY" install go "$TOP" + assert_contains "global go $TOP" "now using go $TOP" "$NVY" global go "$TOP" + assert_contains "exec go version → real Go banner" "go version go$TOP" "$NVY" exec go version + assert_contains "versions go: marks $TOP installed" "* $TOP" "$NVY" versions go + assert_contains "uninstall go $TOP" "uninstalled go $TOP" "$NVY" uninstall go "$TOP" + fi +else + section "Full network install" + skip "skipped — re-run with --full to download and install a real Go toolchain" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +printf "\n${BOLD}Results:${RESET} ${GREEN}%d passed${RESET}, ${RED}%d failed${RESET} (sandbox: %s, removed on exit)\n" \ + "$PASSED" "$FAILED" "$NVY_DIR" +[ "$FAILED" -eq 0 ]