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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ./...
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions cmd/commands_test.go
Original file line number Diff line number Diff line change
@@ -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 .<tool>-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")
}
}
111 changes: 111 additions & 0 deletions cmd/current.go
Original file line number Diff line number Diff line change
@@ -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 .<tool>-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 <version>", 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 <tool> <version>")
}
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
}
44 changes: 44 additions & 0 deletions cmd/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/trevorphillipscoding/nvy/internal/shim"
)

var execCmd = &cobra.Command{
Use: "exec <binary> [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 <binary> [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
}
Loading
Loading