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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ node_modules/
package-lock.json

# Tooling session metadata (not part of the project source tree).
.claude/
.claude/

.conversation-history
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- golangci-lint config (`errcheck`, `staticcheck`, `gocritic`, etc.)
- Makefile with `build`, `test`, `lint`, `fmt`, `ci`, `release-snap`, `release` targets
- GoReleaser config: 6 platform archives, SHA256, Homebrew tap, Scoop bucket
- System-node classifier (`internal/detector/system_node.go`):
classifies the `node` binary on PATH into one of `os-package`,
`snap`, `flatpak`, `homebrew-core`, `manager`, or `unknown`, and
surfaces a one-paragraph warning when the binary is one nodeup
cannot (or should not) manage. Wired into `nodeup upgrade`
(warning to stderr, after manager resolution) and `nodeup check`
(rendered into the table output and the `--json` envelope).
Manager-data-dir overrides (e.g. `NVM_DIR=/usr/local/nvm`)
take precedence so a manager install inside an OS-shaped
directory is still classified as `manager`. Closes #27.

## [0.0.0] - 2024-07-01

Expand Down
35 changes: 35 additions & 0 deletions docs/managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,39 @@ Or via flag:
nodeup upgrade --manager fnm
```

## When `node` on PATH doesn't belong to a manager

nodeup's job is to swap Node versions inside a *version manager* it
manages — `fnm`, `nvm`, `Volta`, `asdf`, `mise`, `n`, `nodenv`, or
`nvm-windows`. If the `node` binary that lives first on your PATH
was installed some other way, nodeup can't safely replace it: the
other installer would just put it back on the next update.

Both `nodeup upgrade` and `nodeup check` classify the `node` on PATH
into one of these buckets:

| Kind | Example paths | nodeup's behavior |
|------------------|----------------------------------------------------------------------|-------------------|
| `manager` | `~/.fnm/node-versions/v22/bin/node`, `~/.nvm/versions/node/...` | Manages normally — no warning. |
| `os-package` | `/usr/bin/node`, `/bin/node`, `/opt/node/...`, `C:\Program Files\nodejs\node.exe`, `~/scoop/apps/nodejs/...` | Prints a warning to stderr (upgrade) or table (check). The platform-specific hint names the right upgrade tool: `sudo apt upgrade nodejs`, `winget upgrade Node.js`, etc. |
| `snap` | `/snap/bin/node`, `/snap/node/<rev>/bin/node` | Warns. Run `snap refresh node`. |
| `flatpak` | `/var/lib/flatpak/runtime/node/...`, `/usr/libexec/flatpak/...` | Warns. Run `flatpak update` (or uninstall the flatpak and let nodeup manage a manager install instead). |
| `homebrew-core` | `/usr/local/bin/node`, `/opt/homebrew/bin/node`, `/usr/local/Cellar/node/...`, `/opt/homebrew/Cellar/node/...`, `/home/linuxbrew/.linuxbrew/bin/node` | Warns. Run `brew upgrade node`, or `brew uninstall node` and let nodeup take over. |
| `unknown` | Anything that doesn't match the patterns above | Soft warning: "nodeup does not recognize this layout." |

The classifier is path-based and runs in two passes: first it asks
"is this inside a manager's data dir?" (so `NVM_DIR=/usr/local/nvm`
beats `/usr/local`'s OS-shape); if not, the binary's install path is
classified by structural cues (`/snap/bin/`, `/usr/bin/`, the
Homebrew wrapper under `/usr/local/bin/node`, etc.).

If you want nodeup to take over a node that's currently a system
install, **uninstall the system copy first** (e.g., `brew uninstall
node`, `sudo apt remove nodejs`, `snap remove node`), then make sure
the manager's shim directory comes earlier on PATH than the system
bin dir (e.g., `fnm env --use-on-cd | source`, or add
`$HOME/.fnm/current/bin` to your shell init). Once `which node`
points at a binary inside the manager's data dir, nodeup's next
run will classify it as `manager` and proceed without warning.

> _Status: Phase 1 ✅ done — all 8 managers detected · last updated 2026-06-29_
132 changes: 114 additions & 18 deletions internal/cli/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
"strings"

"github.com/Masterminds/semver/v3"
"github.com/spf13/cobra"
Expand All @@ -11,6 +12,18 @@ import (
"github.com/dipto0321/nodeup/internal/node"
)

// systemNodeJSON describes the on-disk `node` binary found on PATH,
// if any. Marshal-safe so it can sit inside the top-level check
// JSON envelope. nil in the envelope means "not probed" or "no
// node on PATH", distinguishing that from `path == ""` which can
// only arise if the probe itself succeeded but returned an empty
// path (a defensive guard we don't expect to surface).
type systemNodeJSON struct {
Path string `json:"path"`
Kind string `json:"kind"`
Manager string `json:"manager,omitempty"`
}

// newCheckCmd implements `nodeup check` — show available LTS and Current versions.
// It fetches the nodejs.org/dist/index.json manifest and compares against
// installed versions (if a manager is detected).
Expand Down Expand Up @@ -61,18 +74,43 @@ func runCheck(cmd *cobra.Command, args []string) error {
// Get installed versions if a manager is available
installed := detector.DetectAll()

// Probe `node` on PATH and classify how it's installed. When
// exactly one manager was detected we pass it to the classifier
// so a manager-owned binary on PATH classifies as `manager`
// rather than the path-only fallback (which would otherwise
// surface an "unrecognized layout" for any node living under
// ~/.fnm/ or similar — a perfectly normal manager install).
// With zero or multiple managers we pass nil: nothing to
// attribute, the path classifier handles it.
var sysMgr detector.Manager
if len(installed.Found) == 1 {
sysMgr = installed.Found[0]
}

// The warning text is captured for both the JSON envelope and
// the table renderer.
var sysNode *systemNodeJSON
if info, err := detector.ResolveSystemNode(cmd.Context(), sysMgr); err == nil {
sysNode = &systemNodeJSON{
Path: info.Path,
Kind: info.Kind.String(),
Manager: info.Manager,
}
}

if asJSON {
return outputCheckJSON(cmd, lts, current, installed)
return outputCheckJSON(cmd, lts, current, installed, sysNode)
}

return outputCheckTable(cmd, lts, current, installed)
return outputCheckTable(cmd, lts, current, installed, sysNode)
}

func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry) error {
func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error {
type checkOutput struct {
LTS *node.ManifestVersion `json:"lts"`
Current *node.ManifestVersion `json:"current"`
Installed []string `json:"installed"`
LTS *node.ManifestVersion `json:"lts"`
Current *node.ManifestVersion `json:"current"`
Installed []string `json:"installed"`
SystemNode *systemNodeJSON `json:"systemNode,omitempty"`
}

installedVersions := make([]string, 0)
Expand All @@ -87,9 +125,10 @@ func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, ins
}

out := checkOutput{
LTS: lts,
Current: current,
Installed: installedVersions,
LTS: lts,
Current: current,
Installed: installedVersions,
SystemNode: sysNode,
}

data, err := json.MarshalIndent(out, "", " ")
Expand All @@ -100,30 +139,87 @@ func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, ins
return nil
}

func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry) error {
func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, installed detector.Registry, sysNode *systemNodeJSON) error {
cmd.Println()
cmd.Printf(" LTS: %s (released %s)\n", lts.Version, lts.Date)
cmd.Printf(" Current: %s (released %s)\n", current.Version, current.Date)
cmd.Println()

if len(installed.Found) == 0 {
cmd.Println("No Node.js version manager detected.")
return nil
} else {
cmd.Println("Installed versions:")
for _, m := range installed.Found {
versions, err := m.ListInstalled()
if err != nil {
cmd.Printf(" - %s: [error listing versions]\n", m.Name())
continue
}
cmd.Printf(" - %s: %s\n", m.Name(), formatVersions(versions))
}
}

cmd.Println("Installed versions:")
for _, m := range installed.Found {
versions, err := m.ListInstalled()
if err != nil {
cmd.Printf(" - %s: [error listing versions]\n", m.Name())
continue
// Surface the on-PATH `node` classification. When sysNode is nil
// the probe didn't run (or `which node` failed) — we say so
// explicitly rather than staying silent, so the user has a
// single source of truth for what nodeup sees.
cmd.Println()
if sysNode == nil {
cmd.Println("System node: (could not probe `node` on PATH)")
return nil
}
switch sysNode.Kind {
case "manager":
cmd.Printf("System node: %s (managed by %s)\n", sysNode.Path, sysNode.Manager)
case "unknown":
// Path matched no known layout. Don't print a long warning
// here — `nodeup upgrade` is where the warning belongs.
cmd.Printf("System node: %s (unrecognized layout)\n", sysNode.Path)
default:
// OS-package / snap / flatpak / homebrew-core. Render the
// same warning text that `nodeup upgrade` would print, so
// `check` is a useful diagnostic on its own. We capture
// into a buffer rather than hitting stderr directly to keep
// the table layout coherent.
var buf strings.Builder
_, _ = detector.WarnSystemNode(&buf, detector.SystemNodeInfo{
Path: sysNode.Path,
Kind: parseSystemNodeKind(sysNode.Kind),
Manager: sysNode.Manager,
})
// Indent each rendered line by two spaces so it lines up
// with the rest of the table block.
for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
cmd.Printf(" %s\n", line)
}
cmd.Printf(" - %s: %s\n", m.Name(), formatVersions(versions))
}

return nil
}

// parseSystemNodeKind round-trips a kind label produced by
// SystemNodeKind.String() back to the enum, so outputCheckTable can
// re-render the warning text without re-classifying the path. The
// mapping is intentionally exhaustive: any unknown label resolves
// to SystemNodeUnknown so the caller falls into the soft-warning
// branch.
func parseSystemNodeKind(s string) detector.SystemNodeKind {
switch s {
case "os-package":
return detector.SystemNodeOSPackage
case "snap":
return detector.SystemNodeSnap
case "flatpak":
return detector.SystemNodeFlatpak
case "homebrew-core":
return detector.SystemNodeHomebrewCore
case "manager":
return detector.SystemNodeManaged
default:
return detector.SystemNodeUnknown
}
}

func formatVersions(versions []semver.Version) string {
if len(versions) == 0 {
return "(none)"
Expand Down
34 changes: 34 additions & 0 deletions internal/cli/upgrade.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package cli

import (
"context"
"fmt"
"io"
"os"

"github.com/Masterminds/semver/v3"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -81,6 +84,17 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
}
cmd.Printf("Using manager: %s\n", m.Name())

// Probe the system Node BEFORE we start touching anything. If it
// turns out `node` on PATH is owned by the OS package manager, snap,
// flatpak, or homebrew-core (i.e., NOT inside the manager's data
// directory), surface a warning so the user understands nodeup will
// leave that binary alone and what tool to use instead.
//
// We print to stderr, not stdout, so machine-readable consumers
// (e.g., `nodeup upgrade | jq`) don't get the prose mixed into their
// JSON / table output.
warnSystemNodeIfNeeded(cmd.Context(), m, os.Stderr)

// Fetch versions
var manifest node.Manifest
if offline {
Expand Down Expand Up @@ -237,3 +251,23 @@ func parseVersion(s string) (*semver.Version, error) {
}
return semver.NewVersion(s)
}

// warnSystemNodeIfNeeded is the upgrade-command hook for the
// system-node classifier. It calls ResolveSystemNode with the
// resolved manager, then prints the warning to w (typically
// os.Stderr) when the classifier flags the path as non-managed.
//
// Failure to locate `node` at all is treated as "nothing to warn
// about" — that's a separate concern handled by other code paths.
// Errors during the `which node` probe are silently swallowed
// because the worst case is "we don't print the warning" which is
// strictly better than aborting the upgrade over a path-resolution
// glitch.
func warnSystemNodeIfNeeded(ctx context.Context, m detector.Manager, w io.Writer) {
info, err := detector.ResolveSystemNode(ctx, m)
if err != nil {
// No node on PATH, or `which` itself failed. Not a warning.
return
}
detector.WarnSystemNode(w, info)
}
Loading
Loading