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
25 changes: 25 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# https://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.go]
indent_style = tab
indent_size = 4

[*.{yml,yaml}]
indent_style = space
indent_size = 2

[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false

[Makefile]
indent_style = tab
indent_size = 4
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
commit-message:
prefix: "deps"
labels:
- dependencies
open-pull-requests-limit: 5

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
commit-message:
prefix: "ci"
labels:
- ci
open-pull-requests-limit: 5
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ on:
branches: [main]

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: stable

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest

test:
name: Test
strategy:
Expand All @@ -16,10 +33,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}

Expand Down
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "2"

formatters:
enable:
- gofmt
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# make — build the binary
# make install — install to /usr/local/bin (or GOBIN)
# make test — run tests
# make lint — run golangci-lint
# make cover — run tests and show coverage report
# make clean — remove build artefacts

BINARY := nvy
Expand All @@ -13,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 clean deps cover cover-check
.PHONY: all build install test lint cover cover-check tidy clean deps help

all: build

Expand All @@ -29,6 +31,10 @@ install:
test:
go test ./...

## lint: run golangci-lint (install: https://github.com/golangci/golangci-lint)
lint:
golangci-lint run ./...

## cover: run tests and show coverage report
cover:
go test -coverprofile=coverage.out -coverpkg=$(COVER_PKGS) ./...
Expand Down
100 changes: 94 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# nvy

[![CI](https://github.com/trevorphillipscoding/nvy/actions/workflows/ci.yml/badge.svg)](https://github.com/trevorphillipscoding/nvy/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/trevorphillipscoding/nvy)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/trevorphillipscoding/nvy)](go.mod)

A minimalist, plugin-driven runtime version manager for macOS and Linux.

Install and switch between multiple versions of language runtimes — Go, Node.js, and more — with a single binary built from scratch in Go.
Install and switch between multiple versions of language runtimes — Go, Node.js, Python, and more — with a single binary built from scratch in Go. One tool to replace pyenv, goenv, nvm, fnm, and the rest.

```
$ nvy install go 1.26.0
Expand Down Expand Up @@ -84,6 +86,7 @@ Then restart your terminal.
nvy install go 1.26.0
nvy install go@1.26.0 # same thing
nvy install node 20.11.1
nvy install python 3.12
```

The runtime is installed to `~/.nvy/runtimes/<tool>/<version>/`.
Expand Down Expand Up @@ -134,22 +137,107 @@ node

`*` = global default, `»` = local pin for the current directory.

### Uninstall a version

```sh
nvy uninstall go 1.21.0
```

If the version being removed is the active global, its shims are cleaned up automatically.

---

## Supported runtimes

| Tool | Aliases | Platforms | Official source |
| -------- | ------------------- | ------------ | --------------- |
| `go` | `golang` | macOS, Linux | dl.google.com |
| `node` | `nodejs`, `node.js` | macOS, Linux | nodejs.org |
| `python` | `python3`, `py` | macOS, Linux | python.org |
| 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) |

Supported architectures: `amd64` (x86-64), `arm64` (Apple Silicon / ARM).

Adding a new runtime is straightforward — see [Contributing](#contributing).

---

## How it works

nvy uses a **shim-based execution model** with zero subprocess overhead:

```
~/.nvy/
├── runtimes/<tool>/<version>/ # installed runtime trees
├── shims/ # symlinks to the nvy binary
├── state/
│ ├── global.json # active global versions
│ └── owners.json # binary → tool mapping
└── tmp/ # staging area for installs
```

1. `nvy global go 1.22.1` creates `~/.nvy/shims/go` → symlink to the `nvy` binary
2. When you run `go build`, your shell resolves `~/.nvy/shims/go` → the `nvy` binary
3. nvy detects it was invoked as `go` (via `os.Args[0]`), resolves the active version, and calls `syscall.Exec` to replace itself with the real Go binary
4. The real binary runs directly — no wrapper process, no signal forwarding, no overhead

Version resolution order: **local** `.go-version` file (walking up from cwd) → **global** version → error with setup instructions.

### Plugin architecture

Every runtime is a self-contained plugin implementing a three-method interface:

```go
type Plugin interface {
Name() string
Aliases() []string
Resolve(version, goos, goarch string) (*DownloadSpec, error)
}
```

To add a new runtime (e.g., Ruby, Rust, Deno):

1. Create `plugins/<lang>/<lang>.go` implementing the interface
2. Call `plugins.Register(New())` in the package's `init()` function
3. Add a blank import in `plugins/all/all.go`

That's it. The core install/global/local/list/uninstall commands work automatically.

---

## Security

- **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
- **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
- **No shell evaluation** — version files are read as plain text, never executed

See [SECURITY.md](SECURITY.md) for the full security policy and vulnerability reporting.

---

## Environment variables

| Variable | Default | Description |
| --------- | -------- | ------------------------------- |
| `NVY_DIR` | `~/.nvy` | Override the nvy home directory |

---

## Contributing

Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

```sh
make test # run tests
make lint # run linter
make cover-check # verify coverage threshold
```

---

## License

[MIT](LICENSE)
42 changes: 42 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Security Policy

## Reporting a Vulnerability

If you discover a security vulnerability in nvy, please report it responsibly.

**Do not open a public issue.** Instead, email security concerns to the maintainer or use [GitHub's private vulnerability reporting](https://github.com/trevorphillipscoding/nvy/security/advisories/new).

Please include:

- A description of the vulnerability
- Steps to reproduce the issue
- The potential impact
- Any suggested fixes (if applicable)

You should receive a response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.

## Security Model

nvy is designed with security as a priority:

- **HTTPS only** — all downloads are performed over HTTPS. Plain HTTP URLs are rejected, and redirects to HTTP are blocked.
- **TLS 1.2+** — the HTTP client enforces a minimum TLS version of 1.2.
- **SHA-256 verification** — every downloaded archive is verified against its published SHA-256 checksum before extraction. Mismatches abort the install.
- **Zip Slip protection** — archive extraction validates that no entry escapes the destination directory, preventing path-traversal attacks.
- **Decompression bomb protection** — individual extracted files are capped at 2 GB to prevent disk exhaustion.
- **Atomic installs** — installations use a temp directory + rename strategy, ensuring the runtime directory is never in a partial state.
- **Symlink validation** — absolute symlinks and symlinks that escape the destination are rejected during extraction.
- **No shell evaluation** — version files are read as plain text; their contents are never executed.

## Supported Versions

Security fixes are applied to the latest release only. We recommend always running the most recent version of nvy.

| Version | Supported |
| ------- | --------- |
| latest | Yes |
| older | No |

## Dependencies

nvy has a single external dependency ([cobra](https://github.com/spf13/cobra)) and relies heavily on the Go standard library. We monitor dependencies for known vulnerabilities using `govulncheck` and Dependabot.
16 changes: 11 additions & 5 deletions cmd/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -43,9 +44,14 @@ func runGlobal(_ *cobra.Command, args []string) error {
}
tool = p.Name() // normalise alias

runtimeBinDir := env.RuntimeBinDir(tool, version)
ver, err := resolveInstalledVersion(tool, version)
if err != nil {
return fmt.Errorf("resolving installed version for %s %s: %w", tool, version, err)
}

runtimeBinDir := env.RuntimeBinDir(tool, ver)
if _, statErr := os.Stat(runtimeBinDir); statErr != nil {
return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, version, tool, version)
return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, ver, tool, ver)
}

// The nvy binary itself acts as the shim. When invoked as "go" or "npm",
Expand All @@ -65,11 +71,11 @@ func runGlobal(_ *cobra.Command, args []string) error {
return fmt.Errorf("creating shims: %w", err)
}

if err := state.SetGlobal(tool, version); err != nil {
if err := state.SetGlobal(tool, ver); err != nil {
return fmt.Errorf("saving state: %w", err)
}

fmt.Printf("now using %s %s\n", tool, version)
fmt.Printf("now using %s %s\n", tool, ver)
if len(created) > 0 {
fmt.Printf(" binaries: %s\n", strings.Join(created, ", "))
}
Expand Down Expand Up @@ -107,7 +113,7 @@ func createShims(runtimeBinDir, nvyBinDir, nvyExe, tool string) ([]string, error
continue
}

dst := nvyBinDir + "/" + e.Name()
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)
Expand Down
Loading