diff --git a/.gitignore b/.gitignore index 785c8e2..b6e3241 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ node_modules/ package-lock.json # Tooling session metadata (not part of the project source tree). -.claude/ \ No newline at end of file +.claude/ + +.conversation-history \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a45dd..089e22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/managers.md b/docs/managers.md index 4550be1..205a4c1 100644 --- a/docs/managers.md +++ b/docs/managers.md @@ -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//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_ \ No newline at end of file diff --git a/internal/cli/check.go b/internal/cli/check.go index 7956b61..7f44f0f 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -3,6 +3,7 @@ package cli import ( "encoding/json" "fmt" + "strings" "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" @@ -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). @@ -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) @@ -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, "", " ") @@ -100,7 +139,7 @@ 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) @@ -108,22 +147,79 @@ func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, in 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)" diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 3c47e2d..db7e3cc 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -1,7 +1,10 @@ package cli import ( + "context" "fmt" + "io" + "os" "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" @@ -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 { @@ -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) +} diff --git a/internal/detector/system_node.go b/internal/detector/system_node.go new file mode 100644 index 0000000..04c3633 --- /dev/null +++ b/internal/detector/system_node.go @@ -0,0 +1,558 @@ +package detector + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// SystemNodeKind classifies how a `node` binary is installed. The zero +// value is SystemNodeUnknown. +type SystemNodeKind int + +const ( + // SystemNodeUnknown is the zero value. Returned when the binary + // exists on PATH but doesn't match any known layout, or when no + // `node` binary is on PATH at all (ResolveSystemNode returns an + // error in that case; the zero value is reserved for "not yet + // classified"). + SystemNodeUnknown SystemNodeKind = iota + + // SystemNodeOSPackage: Node is installed by the OS package + // manager (apt, dnf, pacman, scoop, MSI). Examples: /usr/bin/node + // (Debian/Ubuntu/RHEL), C:\Program Files\nodejs\node.exe. + SystemNodeOSPackage + + // SystemNodeSnap: Node installed via snap (Linux). The binary is + // /snap/bin/node with the real files under /snap/node//. + SystemNodeSnap + + // SystemNodeFlatpak: Node installed via flatpak. Rare but + // exists on Flathub — nodeup cannot reach into a flatpak + // runtime to swap versions. + SystemNodeFlatpak + + // SystemNodeHomebrewCore: Node is a homebrew-core formula. + // The binary lives at /usr/local/bin/node (Intel Homebrew) or + // /opt/homebrew/bin/node (Apple Silicon Homebrew), with the + // real files under /usr/local/Cellar/node/ or + // /opt/homebrew/Cellar/node/. Distinct from a Homebrew + // tap formula like `node@22`, which would surface under a + // manager-specific path instead. + SystemNodeHomebrewCore + + // SystemNodeManaged: The binary lives inside a version manager's + // data directory. nodeup can upgrade it. + SystemNodeManaged +) + +// String returns a human-readable label for the kind, suitable for +// the `nodeup upgrade` and `nodeup check` output. SystemNodeUnknown +// stringifies to "unknown"; callers wanting a non-string comparison +// should switch on the enum value directly. +func (k SystemNodeKind) String() string { + switch k { + case SystemNodeOSPackage: + return "os-package" + case SystemNodeSnap: + return "snap" + case SystemNodeFlatpak: + return "flatpak" + case SystemNodeHomebrewCore: + return "homebrew-core" + case SystemNodeManaged: + return "manager" + case SystemNodeUnknown: + return "unknown" + default: + return fmt.Sprintf("kind(%d)", int(k)) + } +} + +// SystemNodeInfo describes the `node` binary nodeup found on the +// user's PATH. Path is the absolute on-disk location of the resolved +// binary. Kind is the classification (see SystemNodeKind). Manager is +// the name of the manager we believe owns the installation when Kind +// is SystemNodeManaged — empty otherwise. +// +// The zero value is meaningful: a SystemNodeInfo{} with an empty Path +// means ResolveSystemNode didn't find a `node` on PATH. +type SystemNodeInfo struct { + Path string + Kind SystemNodeKind + Manager string +} + +// ErrNoNodeOnPATH is returned by ResolveSystemNode when no `node` +// executable can be located. Callers can use errors.Is to detect this +// specifically and decide whether it's worth warning about. +var ErrNoNodeOnPATH = errors.New("`node` not found on PATH") + +// whichNode is the package-level seam used by ResolveSystemNode to +// locate the `node` binary. Tests overwrite it to return canned paths +// without touching the real filesystem. Production code never +// reassigns it. +// +// Signature: returns the absolute path of `node` on PATH, or empty +// string if not found. Returning a path and an error is fine — the +// caller prefers the path when non-empty, the error when path is +// empty. (This matches the LookupManagerBinary convention.) +var whichNode = func(ctx context.Context) (string, error) { + // exec.LookPath walks PATH itself, so we don't depend on the + // `which`/`where` shell-outs being present. On minimal images + // (e.g., distroless containers, alpine installs) those commands + // may be absent even though `node` is on PATH — using LookPath + // avoids that failure mode. It also returns an absolute path + // directly, with no first-line parsing. + if err := ctx.Err(); err != nil { + return "", err + } + p, err := exec.LookPath("node") + if err != nil { + return "", fmt.Errorf("%w: %v", ErrNoNodeOnPATH, err) + } + if p == "" { + return "", ErrNoNodeOnPATH + } + return p, nil +} + +// ResolveSystemNode locates `node` on PATH, classifies it by where it +// lives on disk, and returns the result. Returns ErrNoNodeOnPATH when +// no `node` binary exists. +// +// `node` is located via exec.LookPath (which walks PATH itself, so +// it doesn't depend on the `which`/`where` shell-outs being +// installed) and returns an absolute path. +// +// The classification is path-based and intentional: we never +// try to "use the manager's API" to identify it, because by +// definition a system node is one the manager does NOT see. +// +// If a manager is non-nil and its data directory contains the resolved +// path, Kind becomes SystemNodeManaged and Manager is set to m.Name(). +// Otherwise the path-driven classifier decides. +func ResolveSystemNode(ctx context.Context, m Manager) (SystemNodeInfo, error) { + p, err := whichNode(ctx) + if err != nil { + // Some shells return code 1 for "not found" with empty + // output; collapse to our sentinel so callers can use + // errors.Is cleanly. + if p == "" { + return SystemNodeInfo{}, fmt.Errorf("%w: %v", ErrNoNodeOnPATH, err) + } + } + if p == "" { + return SystemNodeInfo{}, ErrNoNodeOnPATH + } + + // If the binary is inside the manager's data dir, it's clearly + // managed. We try this first because the path-based classifier + // can false-positive on a manager that happens to live under + // /usr/local (rare but possible with self-hosted fnm). + if m != nil { + if managedRoots, ok := managerManagedRoots(m); ok { + for _, root := range managedRoots { + if isInside(p, root) { + return SystemNodeInfo{ + Path: p, + Kind: SystemNodeManaged, + Manager: m.Name(), + }, nil + } + } + } + } + + return SystemNodeInfo{ + Path: p, + Kind: classifySystemNodePath(p), + }, nil +} + +// classifySystemNodePath classifies a path by structural cues. +// Pure function: same input → same output, no I/O. Tests pass +// absolute paths directly. +// +// Order matters: more-specific prefixes win. We check `/snap/bin/` +// before any generic `/usr/local` check, and the Homebrew Cellar +// before `/usr/local/bin/`. +func classifySystemNodePath(p string) SystemNodeKind { + if p == "" { + return SystemNodeUnknown + } + // Normalize for case-insensitive filesystems (Windows, default + // macOS) without losing the original casing, since some Unix + // systems genuinely have differently-cased directories (rare, + // but mount points via casefold can introduce it). + clean := filepath.Clean(p) + + // Use forward slashes for layout checks regardless of OS; + // filepath.ToSlash handles the conversion. This lets us write one + // table for both Windows and unix shapes. + s := filepath.ToSlash(clean) + + switch { + case strings.HasPrefix(s, "/snap/bin/"), isUnder(s, "/snap/node/"), isUnder(s, "/var/lib/snapd/snap/node/"): + return SystemNodeSnap + case isUnder(s, "/var/lib/flatpak/runtime/node"): + // Flatpak runtimes live under /var/lib/flatpak/runtime// + // //active/files/... + return SystemNodeFlatpak + case strings.HasPrefix(s, "/usr/libexec/flatpak"), isUnder(s, "/usr/lib/flatpak/"): + return SystemNodeFlatpak + case isUnder(s, "/usr/local/Cellar/node/"), isUnder(s, "/opt/homebrew/Cellar/node/"): + return SystemNodeHomebrewCore + case strings.HasPrefix(s, "/usr/local/bin/") && runtime.GOOS == "darwin" && looksLikeHomebrewCoreLayout(s): + // `/usr/local/bin/node` is the Homebrew wrapper symlink that + // ultimately points at /usr/local/Cellar/node//bin/node. + // We restrict this branch to macOS because on Linux, + // `/usr/local/bin/node` is overwhelmingly a manual compile + // (`make install`) — Homebrew on Linux lives at + // /home/linuxbrew/.linuxbrew/bin/node (handled below), not + // /usr/local/bin/. Misclassifying the manual install as + // homebrew-core would tell the user to run `brew upgrade`, + // which doesn't exist on their system. + return SystemNodeHomebrewCore + case strings.HasPrefix(s, "/opt/homebrew/bin/"), strings.HasPrefix(s, "/opt/homebrew/opt/node/"), strings.HasPrefix(s, "/home/linuxbrew/.linuxbrew/bin/"): + // Apple Silicon Homebrew and Linuxbrew both land here. + return SystemNodeHomebrewCore + case strings.HasPrefix(s, "/usr/bin/"), strings.HasPrefix(s, "/bin/"): + // Debian/Ubuntu/RHEL/Fedora/Arch all install Node here via + // their package manager. + return SystemNodeOSPackage + case strings.HasPrefix(s, "/usr/sbin/"), strings.HasPrefix(s, "/sbin/"): + // Some SUSE / Solaris layouts ship /usr/sbin/node. Same + // caveat: don't manage it. + return SystemNodeOSPackage + case strings.HasPrefix(s, "/opt/node/"): + // Some custom vendor packages drop Node into /opt/node/. + return SystemNodeOSPackage + case strings.HasPrefix(s, "/usr/local/bin/") && !looksLikeNVMInstall(clean): + // /usr/local/bin/node on systems without Homebrew is usually + // a manual compile (`make install` from source) — also not + // safe for nodeup to touch. The nvm install path bypass + // handles the rare NVM_DIR=/usr/local/nvm case. + return SystemNodeOSPackage + case strings.HasPrefix(s, "/opt/local/bin/"): + // MacPorts default prefix. Same story as OS-package: don't + // touch. + return SystemNodeOSPackage + } + + // Windows layouts. Checked last because Unix paths are far more + // common in CI matrices; the cost of an extra strings.HasPrefix + // on each call is irrelevant. + if strings.HasPrefix(s, "C:/Program Files/nodejs/") || strings.HasPrefix(s, "C:/Program Files (x86)/nodejs/") { + // The official Windows MSI drops node.exe here. Updates + // come from the same MSI (or winget/scoop/choco). + return SystemNodeOSPackage + } + if strings.Contains(s, "/scoop/apps/nodejs/") { + // Scoop-installed node lands under ~/scoop/apps/nodejs/ + // (or any user's ~/scoop/...); Scoop upgrades handle this + // — nodeup leaves it alone. We match on the directory + // component rather than a leading-slash prefix because + // the user's home is somewhere on the path. + return SystemNodeOSPackage + } + + return SystemNodeUnknown +} + +// looksLikeHomebrewCoreLayout returns true when a path under +// /usr/local looks like it's the Homebrew wrapper rather than a +// plain "compiled and dropped into /usr/local" install. The +// caller is responsible for restricting this to darwin — Linux's +// /usr/local/bin is overwhelmingly a manual-install location. +// +// Without evaluating the symlink itself (which is sensitive to the +// user's actual filesystem), we approximate with the directory +// layout: `…/bin/node` is too generic to call, so we return true +// only when the path matches patterns we know Homebrew uses: +// +// - /usr/local/bin/node (wrapper symlink) +// +// The earlier Cellar prefix check in classifySystemNodePath has +// already handled the real-binary case (/usr/local/Cellar/node/...); +// this helper exists to confirm the wrapper. On Intel macOS, +// Homebrew always uses /usr/local/bin/node as its wrapper, so any +// path ending in /bin/node under /usr/local is taken to mean the +// Homebrew shim. +func looksLikeHomebrewCoreLayout(s string) bool { + // Any path ending in /bin/node under /usr/local is the + // Homebrew wrapper. Without that suffix (e.g., /usr/local/bin/ + // other-tool), we don't make a claim. + return strings.HasSuffix(s, "/bin/node") || strings.HasSuffix(s, "/node") +} + +// looksLikeNVMInstall is a best-effort guard for the rare case +// where NVM_DIR is set to /usr/local/nvm. We don't want to classify +// /usr/local/nvm/versions/node//bin/node as an OS-package install +// just because its ancestor is /usr/local. Today this is more +// diagnostic than enforced — the path-prefix check is the primary +// discriminator — but the function is here so a future improvement +// can hook this signal without churning callers. +// +// The actual detection: if the path contains "/.nvm/" or +// "/nvm/versions/" anywhere along it, it's an nvm install +// regardless of where NVM_DIR points. Note this helper is +// deliberately nvm-only: fnm/volta/asdf/mise/n/nodenv are +// detected upstream via the manager-override path in +// ResolveSystemNode, not by name patterns here. +func looksLikeNVMInstall(cleanPath string) bool { + return strings.Contains(cleanPath, string(filepath.Separator)+".nvm"+string(filepath.Separator)) || + strings.Contains(cleanPath, string(filepath.Separator)+"nvm"+string(filepath.Separator)+"versions") +} + +// isInside reports whether child (an absolute path) is the same as +// or a descendant of parent (also absolute). It uses filepath.Rel +// so leading ".." segments produced by a non-absolute parent +// surface as "not inside". Both arguments are cleaned first — +// filepath.Rel is confused by trailing slashes and intermediate +// "."/".." segments (e.g., /a/b/c/..) which would otherwise +// produce false negatives for semantically-inside paths. +func isInside(child, parent string) bool { + if child == "" || parent == "" { + return false + } + cleanChild := filepath.Clean(child) + cleanParent := filepath.Clean(parent) + rel, err := filepath.Rel(cleanParent, cleanChild) + if err != nil { + return false + } + if rel == "." { + return true + } + if strings.HasPrefix(rel, "..") || strings.HasPrefix(rel, string(filepath.Separator)+"..") { + return false + } + // Any remaining ".." segment also counts as outside. + for _, seg := range strings.Split(rel, string(filepath.Separator)) { + if seg == ".." { + return false + } + } + return true +} + +// isUnder is the slash-normalized version of isInside. Useful when +// comparing the post-ToSlash path string against hard-coded prefixes +// containing "/". +func isUnder(s, prefix string) bool { + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + return strings.HasPrefix(s, prefix) +} + +// managerManagedRoots returns the filesystem roots under which a +// given manager stores its Node installs. We try to resolve at +// runtime via environment variables when the manager doesn't +// expose a direct getter; for paths the manager doesn't expose, +// this function returns ok=false and the classifier falls back to +// path patterns. +// +// Each root is a directory: a resolved Node binary whose path +// lives at or under this root is considered manager-managed. +// +// We keep this conservative: an empty slice with ok=true means +// "this manager exists, but we don't know its root — fall through +// to path-based classification". ok=false means "we have nothing +// to say about this manager" — either the manager is nil, or its +// name isn't one we recognize (unknown/unsupported manager). +func managerManagedRoots(m Manager) (roots []string, ok bool) { + if m == nil { + return nil, false + } + switch m.Name() { + case "fnm": + // fnm defaults to $XDG_DATA_HOME/fnm on Linux and + // ~/Library/Application Support/fnm on macOS, with + // ~/.fnm still respected as a legacy path. We enumerate + // every plausible root so the path classifier still + // recognizes an fnm-managed install when FNM_DIR is + // unset. The env override is checked first. + if d := strings.TrimSpace(getenv("FNM_DIR")); d != "" { + return []string{d}, true + } + roots := []string{} + if h, err := userHomeDir(); err == nil { + roots = append(roots, + filepath.Join(h, ".fnm"), + filepath.Join(h, "Library", "Application Support", "fnm"), + ) + if xdg := strings.TrimSpace(getenv("XDG_DATA_HOME")); xdg != "" { + roots = append(roots, filepath.Join(xdg, "fnm")) + } else { + roots = append(roots, filepath.Join(h, ".local", "share", "fnm")) + } + } + return roots, true + case "nvm": + if d := strings.TrimSpace(getenv("NVM_DIR")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, ".nvm")}, true + } + return []string{}, true + case "volta": + if d := strings.TrimSpace(getenv("VOLTA_HOME")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, ".volta")}, true + } + return []string{}, true + case "asdf": + if d := strings.TrimSpace(getenv("ASDF_DIR")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, ".asdf")}, true + } + return []string{}, true + case "mise": + // mise stores installs under XDG_DATA_HOME/mise (default + // ~/.local/share/mise). Override via MISE_DATA_DIR. + if d := strings.TrimSpace(getenv("MISE_DATA_DIR")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, ".local", "share", "mise")}, true + } + return []string{}, true + case "n": + // n defaults to ~/n; override via N_PREFIX. + if d := strings.TrimSpace(getenv("N_PREFIX")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, "n")}, true + } + return []string{}, true + case "nodenv": + // nodenv defaults to ~/.nodenv; override via NODENV_ROOT. + if d := strings.TrimSpace(getenv("NODENV_ROOT")); d != "" { + return []string{d}, true + } + if h, err := userHomeDir(); err == nil { + return []string{filepath.Join(h, ".nodenv")}, true + } + return []string{}, true + } + // nvm-windows, scoop, and any unknown manager: no recognized + // root pattern; fall through to path-based classification. + return []string{}, false +} + +// WarnSystemNode writes a one-paragraph user-facing warning to w when +// info identifies a node binary that nodeup cannot (and should not) +// manage. Returns the warning text — same string sent to w — so the +// caller can choose to record it (e.g., in JSON output) instead of +// printing it. +// +// Returns ("", false) when no warning is warranted: +// +// - info.Kind == SystemNodeManaged: this is exactly what nodeup +// upgrades; no warning needed. +// - info.Path == "" (regardless of Kind): nothing was detected on +// PATH; that's a separate problem (no node at all) handled +// elsewhere (ErrNoNodeOnPATH). +// +// Otherwise (any non-managed Kind with a non-empty Path, including +// SystemNodeUnknown), a warning IS emitted — a soft one in the +// SystemNodeUnknown case so the user can decide whether their +// unrecognized layout is safe to overwrite. +// +// The text is plain prose by design: integration tests assert on +// substrings, and `nodeup upgrade` and `nodeup check` both render +// it as-is into their tabular output. +func WarnSystemNode(w io.Writer, info SystemNodeInfo) (string, bool) { + if info.Kind == SystemNodeManaged { + return "", false + } + if info.Path == "" { + return "", false + } + + var ( + header string + why string + how string + ) + + switch info.Kind { + case SystemNodeOSPackage: + header = "Warning: detected an OS-installed Node.js." + why = fmt.Sprintf( + "`node` on PATH (%s) was installed by the operating system's package manager.\nnodeup will not overwrite it — your package manager owns this binary and would\nreplace it on the next system update.", + info.Path, + ) + how = systemNodeOSPackageHint() + case SystemNodeSnap: + header = "Warning: detected a snap-installed Node.js." + why = fmt.Sprintf( + "`node` on PATH (%s) is a snap package. snap confinement prevents nodeup\nfrom swapping in a different version.", + info.Path, + ) + how = "Run `snap refresh node` (or `sudo snap refresh node`) to upgrade." + case SystemNodeFlatpak: + header = "Warning: detected a flatpak Node.js runtime." + why = fmt.Sprintf( + "`node` on PATH (%s) is a flatpak runtime. flatpak install sandboxes prevent\nnodeup from managing the runtime's internal Node version.", + info.Path, + ) + how = "Update via `flatpak update` or remove the flatpak and use a manager instead." + case SystemNodeHomebrewCore: + header = "Warning: detected a Homebrew-core Node.js." + why = fmt.Sprintf( + "`node` on PATH (%s) is the homebrew-core formula. nodeup cannot replace\nit because Homebrew owns the symlink chain and would recreate it on `brew update`.", + info.Path, + ) + how = "Run `brew upgrade node`, or `brew uninstall node` and let nodeup manage a\nmanager-installed Node going forward." + default: + header = "Warning: detected a Node.js nodeup does not recognize." + why = fmt.Sprintf( + "`node` on PATH (%s) did not match a known version-manager or system install.\nnodeup cannot determine whether it is safe to overwrite.", + info.Path, + ) + how = "If this is a system-managed Node, use your OS package manager to upgrade it.\nIf it belongs to a version manager nodeup doesn't know about, please open an issue." + } + + text := fmt.Sprintf("%s\n\n%s\n\n%s\n", header, why, how) + _, _ = io.WriteString(w, text) + return text, true +} + +// systemNodeOSPackageHint returns a hint string tailored to the +// platform: brew on macOS, apt on Debian/Ubuntu, dnf on Fedora, +// winget/scoop/choco on Windows. +func systemNodeOSPackageHint() string { + switch runtime.GOOS { + case "darwin": + return "If you used Homebrew to install Node, run `brew upgrade node`.\nOtherwise the OS-bundled binary will come back on the next macOS update." + case "windows": + return "Re-run the official Node.js MSI from nodejs.org to upgrade, or use\n`winget upgrade Node.js`. If you used scoop/choco, run `scoop update node`\nor `choco upgrade nodejs`." + case "linux": + return "Use your distribution's package manager: `sudo apt upgrade nodejs`,\n`sudo dnf upgrade nodejs`, or your distro's equivalent." + default: + return "Use your operating system's package manager to upgrade." + } +} + +// Marker consts used by tests to assert the warning text contains the +// expected fragments. These are package-private so they don't leak +// into the public API surface. +const ( + _systemNodeOSPkgHintDarwin = "brew upgrade node" + _systemNodeOSPkgHintLinux = "sudo apt upgrade nodejs" + _systemNodeOSPkgHintWin = "winget upgrade Node.js" +) diff --git a/internal/detector/system_node_helpers.go b/internal/detector/system_node_helpers.go new file mode 100644 index 0000000..17a3193 --- /dev/null +++ b/internal/detector/system_node_helpers.go @@ -0,0 +1,27 @@ +// system_node_helpers.go holds the test-seam helpers used by +// system_node.go. Splitting them into their own file keeps the +// production logic in system_node.go uncluttered while still +// making the seams discoverable to test authors. +// +// Both helpers wrap package-level vars so tests can stub them +// with t.Cleanup, matching the pattern used by runShell in fnm.go +// and runScript in nvm.go. +package detector + +import "os" + +// getenv is a package-level seam around os.Getenv used by +// managerManagedRoots. Tests overwrite it to inject canned +// environment-variable values without mutating process state. +// Production code never reassigns it. +// +// Signature matches os.Getenv so a direct assignment works. +var getenv = os.Getenv + +// userHomeDir is a package-level seam around os.UserHomeDir used +// by managerManagedRoots. Tests overwrite it to return canned +// home-directory paths without touching the real filesystem. +// Production code never reassigns it. +// +// Signature matches os.UserHomeDir so a direct assignment works. +var userHomeDir = os.UserHomeDir diff --git a/internal/detector/system_node_test.go b/internal/detector/system_node_test.go new file mode 100644 index 0000000..46493ab --- /dev/null +++ b/internal/detector/system_node_test.go @@ -0,0 +1,653 @@ +package detector + +import ( + "bytes" + "context" + "errors" + "io" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +// --- SystemNodeKind.String -------------------------------------------------- + +func TestSystemNodeKindString(t *testing.T) { + cases := []struct { + in SystemNodeKind + want string + }{ + {SystemNodeUnknown, "unknown"}, + {SystemNodeOSPackage, "os-package"}, + {SystemNodeSnap, "snap"}, + {SystemNodeFlatpak, "flatpak"}, + {SystemNodeHomebrewCore, "homebrew-core"}, + {SystemNodeManaged, "manager"}, + {SystemNodeKind(99), "kind(99)"}, + } + for _, c := range cases { + if got := c.in.String(); got != c.want { + t.Errorf("SystemNodeKind(%d).String() = %q, want %q", int(c.in), got, c.want) + } + } +} + +// --- classifySystemNodePath ------------------------------------------------ + +func TestClassifySystemNodePath(t *testing.T) { + cases := []struct { + name string + path string + want SystemNodeKind + }{ + // Snap variants — /snap/bin/ wrapper, /snap/node//, and + // /var/lib/snapd/snap/node// (the snapd-internal layout). + {name: "snap wrapper", path: "/snap/bin/node", want: SystemNodeSnap}, + {name: "snap internal", path: "/snap/node/1234/bin/node", want: SystemNodeSnap}, + {name: "snapd layout", path: "/var/lib/snapd/snap/node/1234/bin/node", want: SystemNodeSnap}, + + // Flatpak runtimes. + {name: "flatpak runtime", path: "/var/lib/flatpak/runtime/node/x86_64/stable/active/files/bin/node", want: SystemNodeFlatpak}, + {name: "flatpak libexec", path: "/usr/libexec/flatpak/abc/bin/node", want: SystemNodeFlatpak}, + {name: "flatpak lib", path: "/usr/lib/flatpak/abc/bin/node", want: SystemNodeFlatpak}, + + // Homebrew core — Intel (/usr/local), Apple Silicon (/opt/homebrew), + // Linuxbrew, and the Cellar layout under each. The Intel-mac + // /usr/local/bin/node wrapper only classifies as Homebrew on + // darwin; on Linux that path is overwhelmingly a manual + // `make install` (Homebrew on Linux lives under + // /home/linuxbrew/.linuxbrew, handled separately). + {name: "homebrew intel cellar", path: "/usr/local/Cellar/node/22.0.0/bin/node", want: SystemNodeHomebrewCore}, + {name: "homebrew apple silicon cellar", path: "/opt/homebrew/Cellar/node/22.0.0/bin/node", want: SystemNodeHomebrewCore}, + {name: "homebrew apple silicon wrapper", path: "/opt/homebrew/bin/node", want: SystemNodeHomebrewCore}, + {name: "homebrew opt", path: "/opt/homebrew/opt/node/bin/node", want: SystemNodeHomebrewCore}, + {name: "linuxbrew", path: "/home/linuxbrew/.linuxbrew/bin/node", want: SystemNodeHomebrewCore}, + + // OS-package — apt/dnf/pacman and the various vendor prefixes. + {name: "debian usr bin", path: "/usr/bin/node", want: SystemNodeOSPackage}, + {name: "legacy bin", path: "/bin/node", want: SystemNodeOSPackage}, + {name: "suse usr sbin", path: "/usr/sbin/node", want: SystemNodeOSPackage}, + {name: "legacy sbin", path: "/sbin/node", want: SystemNodeOSPackage}, + {name: "vendor opt", path: "/opt/node/bin/node", want: SystemNodeOSPackage}, + {name: "macports", path: "/opt/local/bin/node", want: SystemNodeOSPackage}, + + // Windows — official MSI and Scoop. + {name: "windows msi", path: "C:/Program Files/nodejs/node.exe", want: SystemNodeOSPackage}, + {name: "windows msi x86", path: "C:/Program Files (x86)/nodejs/node.exe", want: SystemNodeOSPackage}, + {name: "windows scoop", path: "C:/Users/me/scoop/apps/nodejs/22.0.0/node.exe", want: SystemNodeOSPackage}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifySystemNodePath(c.path); got != c.want { + t.Errorf("classifySystemNodePath(%q) = %s, want %s", c.path, got, c.want) + } + }) + } +} + +func TestClassifySystemNodePath_PlatformSpecific(t *testing.T) { + // `/usr/local/bin/node` is the Homebrew wrapper on macOS but a + // manual `make install` on Linux. The classifier branches on + // runtime.GOOS, so the expected result depends on the host. + cases := []struct { + goos string + want SystemNodeKind + }{ + {"darwin", SystemNodeHomebrewCore}, + {"linux", SystemNodeOSPackage}, + } + for _, c := range cases { + t.Run(c.goos, func(t *testing.T) { + if runtime.GOOS != c.goos { + t.Skipf("skipping: host is %s, not %s", runtime.GOOS, c.goos) + } + if got := classifySystemNodePath("/usr/local/bin/node"); got != c.want { + t.Errorf("classifySystemNodePath(%q) on %s = %s, want %s", + "/usr/local/bin/node", runtime.GOOS, got, c.want) + } + }) + } +} + +func TestClassifySystemNodePath_Unknown(t *testing.T) { + // Paths that don't match any known layout should resolve to + // SystemNodeUnknown so the caller can decide what to do. + cases := []string{ + "", // empty + "/random/path/node", // not under any known prefix + "/home/user/.nvm/versions/node/22.0.0/bin/node", // nvm-managed; classifier leaves it Unknown (manager-override is ResolveSystemNode's job) + } + for _, p := range cases { + if got := classifySystemNodePath(p); got != SystemNodeUnknown { + t.Errorf("classifySystemNodePath(%q) = %s, want %s", p, got, SystemNodeUnknown) + } + } +} + +// --- isInside / isUnder ---------------------------------------------------- + +func TestIsInside(t *testing.T) { + cases := []struct { + child, parent string + want bool + }{ + {"/a/b", "/a/b", true}, // same path + {"/a/b/c", "/a/b", true}, // direct descendant + {"/a/b/c/d", "/a/b", true}, // grand-descendant + {"/a/bb", "/a/b", false}, // sibling-prefix false-positive guard + {"/a", "/a/b", false}, // parent is descendant, not ancestor + {"", "/a", false}, // empty child + {"/a", "", false}, // empty parent + {"/a/../b", "/a", false}, // ".." segments + {"/a/b/c/..", "/a", true}, // child cleaned: /a/b → still inside /a + {"/a/b/./c", "/a/b", true}, // child cleaned: "." segment collapses + } + for _, c := range cases { + if got := isInside(c.child, c.parent); got != c.want { + t.Errorf("isInside(%q, %q) = %v, want %v", c.child, c.parent, got, c.want) + } + } +} + +func TestIsUnder(t *testing.T) { + if !isUnder("/a/b/c", "/a/b") { + t.Error("isUnder should treat missing trailing slash as prefix-with-slash") + } + if !isUnder("/a/b/c", "/a/b/") { + t.Error("isUnder should match direct descendant") + } + if isUnder("/a/bb/c", "/a/b") { + t.Error("isUnder should NOT match sibling-prefix") + } + if isUnder("/a/b", "/a/b") { + t.Error("isUnder requires the trailing slash; /a/b is NOT under /a/b") + } +} + +// --- ResolveSystemNode ----------------------------------------------------- + +// stubWhichNode replaces the package-level whichNode seam for the +// duration of the test, returning (path, err) when the test function +// runs and restoring the production value via t.Cleanup. +func stubWhichNode(t *testing.T, fn func(ctx context.Context) (string, error)) { + t.Helper() + orig := whichNode + t.Cleanup(func() { whichNode = orig }) + whichNode = fn +} + +func TestResolveSystemNode_NoNodeOnPATH(t *testing.T) { + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "", nil // which silently returned empty + }) + _, err := ResolveSystemNode(context.Background(), nil) + if !errors.Is(err, ErrNoNodeOnPATH) { + t.Errorf("err = %v, want ErrNoNodeOnPATH", err) + } +} + +func TestResolveSystemNode_WhichErrorWrapped(t *testing.T) { + // When whichNode returns both an error and an empty path, the + // error must be wrapped with ErrNoNodeOnPATH so callers can + // errors.Is it. + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "", errors.New("which failed") + }) + _, err := ResolveSystemNode(context.Background(), nil) + if !errors.Is(err, ErrNoNodeOnPATH) { + t.Errorf("err = %v, want wrap of ErrNoNodeOnPATH", err) + } + if !strings.Contains(err.Error(), "which failed") { + t.Errorf("err message should preserve original: %v", err) + } +} + +func TestResolveSystemNode_PathClassifiedNoManager(t *testing.T) { + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "/usr/bin/node", nil + }) + info, err := ResolveSystemNode(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Path != "/usr/bin/node" { + t.Errorf("Path = %q, want %q", info.Path, "/usr/bin/node") + } + if info.Kind != SystemNodeOSPackage { + t.Errorf("Kind = %s, want %s", info.Kind, SystemNodeOSPackage) + } + if info.Manager != "" { + t.Errorf("Manager = %q, want empty", info.Manager) + } +} + +func TestResolveSystemNode_ManagerOverrideWins(t *testing.T) { + // Even though /usr/local/bin/node classifies as HomebrewCore on + // its own, if it lives under NVM_DIR (a manager-data-dir), the + // manager-override should win and Kind becomes SystemNodeManaged. + t.Setenv("NVM_DIR", "/usr/local/nvm") + + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "/usr/local/nvm/versions/node/v22.0.0/bin/node", nil + }) + + // Build a fake manager that names itself "nvm". We don't need + // the full Manager interface — just Name() is consulted by + // managerManagedRoots. + mgr := fakeManager{name: "nvm"} + + info, err := ResolveSystemNode(context.Background(), mgr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Kind != SystemNodeManaged { + t.Errorf("Kind = %s, want %s (manager-override should win)", info.Kind, SystemNodeManaged) + } + if info.Manager != "nvm" { + t.Errorf("Manager = %q, want %q", info.Manager, "nvm") + } +} + +func TestResolveSystemNode_ManagerOverrideSkippedWhenPathOutsideRoot(t *testing.T) { + // The path doesn't actually live under NVM_DIR, so the + // manager-override should NOT fire — the path classifier + // decides. + t.Setenv("NVM_DIR", "/home/me/.nvm") + + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "/usr/bin/node", nil + }) + + mgr := fakeManager{name: "nvm"} + info, err := ResolveSystemNode(context.Background(), mgr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Kind != SystemNodeOSPackage { + t.Errorf("Kind = %s, want %s", info.Kind, SystemNodeOSPackage) + } + if info.Manager != "" { + t.Errorf("Manager = %q, want empty (manager root not matched)", info.Manager) + } +} + +func TestResolveSystemNode_NilManagerSafe(t *testing.T) { + // Pass nil manager explicitly. Should classify by path only + // (no manager-override step). + stubWhichNode(t, func(ctx context.Context) (string, error) { + return "/usr/bin/node", nil + }) + info, err := ResolveSystemNode(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Kind != SystemNodeOSPackage { + t.Errorf("Kind = %s, want %s", info.Kind, SystemNodeOSPackage) + } +} + +// --- managerManagedRoots --------------------------------------------------- + +func TestManagerManagedRoots(t *testing.T) { + // Save and restore userHomeDir seam. We override to a known + // home dir so the ~/. fallback produces deterministic + // output regardless of the test runner's HOME. + origHome := userHomeDir + t.Cleanup(func() { userHomeDir = origHome }) + userHomeDir = func() (string, error) { return "/home/tester", nil } + + // Clear every env var the production code reads via getenv(). + // A test runner that has fnm/volta/asdf/etc. installed will + // have these set in the real env, which would make the + // "fall back to home" assertions noisy. t.Setenv saves and + // restores each one to its pre-test value. + for _, v := range []string{ + "FNM_DIR", "NVM_DIR", "VOLTA_HOME", "ASDF_DIR", + "MISE_DATA_DIR", "N_PREFIX", "NODENV_ROOT", + "XDG_DATA_HOME", + } { + t.Setenv(v, "") + } + + cases := []struct { + name string + mgr string + envKey string + envVal string + wantRoots []string + wantOK bool + description string + }{ + { + name: "fnm env wins over home fallback", + mgr: "fnm", envKey: "FNM_DIR", envVal: "/custom/fnm", + wantRoots: []string{"/custom/fnm"}, + wantOK: true, + description: "env override takes precedence", + }, + { + name: "fnm falls back to home (no env)", + mgr: "fnm", + wantRoots: []string{ + filepath.Join("/home/tester", ".fnm"), + filepath.Join("/home/tester", "Library", "Application Support", "fnm"), + filepath.Join("/home/tester", ".local", "share", "fnm"), + }, + wantOK: true, + description: "no env → enumerate plausible XDG + legacy roots", + }, + { + name: "fnm with XDG_DATA_HOME override", + mgr: "fnm", envKey: "XDG_DATA_HOME", envVal: "/srv/data", + wantRoots: []string{ + filepath.Join("/home/tester", ".fnm"), + filepath.Join("/home/tester", "Library", "Application Support", "fnm"), + filepath.Join("/srv/data", "fnm"), + }, + wantOK: true, + description: "XDG_DATA_HOME wins over ~/.local/share", + }, + { + name: "nvm env wins", + mgr: "nvm", envKey: "NVM_DIR", envVal: "/usr/local/nvm", + wantRoots: []string{"/usr/local/nvm"}, + wantOK: true, + description: "NVM_DIR is the canonical override", + }, + { + name: "volta env wins", + mgr: "volta", envKey: "VOLTA_HOME", envVal: "/srv/volta", + wantRoots: []string{"/srv/volta"}, + wantOK: true, + description: "VOLTA_HOME override", + }, + { + name: "asdf env wins", + mgr: "asdf", envKey: "ASDF_DIR", envVal: "/opt/asdf", + wantRoots: []string{"/opt/asdf"}, + wantOK: true, + description: "ASDF_DIR override", + }, + { + name: "mise env wins", + mgr: "mise", envKey: "MISE_DATA_DIR", envVal: "/srv/mise", + wantRoots: []string{"/srv/mise"}, + wantOK: true, + description: "MISE_DATA_DIR override", + }, + { + name: "n env wins", + mgr: "n", envKey: "N_PREFIX", envVal: "/opt/n", + wantRoots: []string{"/opt/n"}, + wantOK: true, + description: "N_PREFIX override", + }, + { + name: "nodenv env wins", + mgr: "nodenv", envKey: "NODENV_ROOT", envVal: "/srv/nodenv", + wantRoots: []string{"/srv/nodenv"}, + wantOK: true, + description: "NODENV_ROOT override", + }, + { + name: "mise falls back to XDG default", + mgr: "mise", + wantRoots: []string{filepath.Join("/home/tester", ".local", "share", "mise")}, + wantOK: true, + description: "no env → ~/.local/share/mise (XDG default)", + }, + { + name: "unknown manager returns ok=false", + mgr: "futuremgr", + wantRoots: nil, + wantOK: false, + description: "we don't recognize the manager, so the classifier falls through to path patterns", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.envKey != "" { + t.Setenv(c.envKey, c.envVal) + } + gotRoots, gotOK := managerManagedRoots(fakeManager{name: c.mgr}) + if gotOK != c.wantOK { + t.Errorf("ok = %v, want %v (%s)", gotOK, c.wantOK, c.description) + } + if !equalStringSlices(gotRoots, c.wantRoots) { + t.Errorf("roots = %v, want %v (%s)", gotRoots, c.wantRoots, c.description) + } + }) + } +} + +func TestManagerManagedRoots_NilManager(t *testing.T) { + gotRoots, gotOK := managerManagedRoots(nil) + if gotOK { + t.Errorf("ok = true, want false for nil manager") + } + if gotRoots != nil { + t.Errorf("roots = %v, want nil for nil manager", gotRoots) + } +} + +// --- WarnSystemNode -------------------------------------------------------- + +func TestWarnSystemNode_ManagedReturnsFalse(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/home/me/.fnm/node-versions/v22.0.0/installation/bin/node", + Kind: SystemNodeManaged, + Manager: "fnm", + }) + if ok { + t.Errorf("ok = true, want false for SystemNodeManaged") + } + if text != "" { + t.Errorf("text = %q, want empty", text) + } + if buf.Len() != 0 { + t.Errorf("buf = %q, want empty writer output", buf.String()) + } +} + +func TestWarnSystemNode_EmptyPathReturnsFalse(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{Path: "", Kind: SystemNodeUnknown}) + if ok { + t.Errorf("ok = true, want false for empty path") + } + if text != "" { + t.Errorf("text = %q, want empty", text) + } +} + +func TestWarnSystemNode_OSPackage(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/usr/bin/node", + Kind: SystemNodeOSPackage, + }) + if !ok { + t.Fatalf("ok = false, want true") + } + if text == "" { + t.Fatalf("text = empty, want non-empty warning") + } + // The text should round-trip: same string written to the + // buffer as returned to the caller. + if buf.String() != text { + t.Errorf("buf.String() != text; buf=%q text=%q", buf.String(), text) + } + // Header is stable. + if !strings.Contains(text, "Warning: detected an OS-installed Node.js.") { + t.Errorf("text missing OS-package header: %q", text) + } + // Path appears verbatim. + if !strings.Contains(text, "/usr/bin/node") { + t.Errorf("text missing path: %q", text) + } + // The platform-tailored hint must match the current OS. + switch runtime.GOOS { + case "darwin": + if !strings.Contains(text, _systemNodeOSPkgHintDarwin) { + t.Errorf("darwin hint missing: %q", text) + } + case "windows": + if !strings.Contains(text, _systemNodeOSPkgHintWin) { + t.Errorf("windows hint missing: %q", text) + } + default: + if !strings.Contains(text, _systemNodeOSPkgHintLinux) { + t.Errorf("linux hint missing: %q", text) + } + } +} + +func TestWarnSystemNode_Snap(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/snap/bin/node", + Kind: SystemNodeSnap, + }) + if !ok { + t.Fatalf("ok = false, want true") + } + if !strings.Contains(text, "snap package") { + t.Errorf("text missing 'snap package': %q", text) + } + if !strings.Contains(text, "snap refresh node") { + t.Errorf("text missing snap refresh hint: %q", text) + } +} + +func TestWarnSystemNode_Flatpak(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/var/lib/flatpak/runtime/node/x86_64/stable/active/files/bin/node", + Kind: SystemNodeFlatpak, + }) + if !ok { + t.Fatalf("ok = false, want true") + } + if !strings.Contains(text, "flatpak runtime") { + t.Errorf("text missing 'flatpak runtime': %q", text) + } + if !strings.Contains(text, "flatpak update") { + t.Errorf("text missing flatpak update hint: %q", text) + } +} + +func TestWarnSystemNode_HomebrewCore(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/usr/local/bin/node", + Kind: SystemNodeHomebrewCore, + }) + if !ok { + t.Fatalf("ok = false, want true") + } + if !strings.Contains(text, "homebrew-core formula") { + t.Errorf("text missing homebrew-core reference: %q", text) + } + if !strings.Contains(text, "brew upgrade node") { + t.Errorf("text missing brew upgrade hint: %q", text) + } +} + +func TestWarnSystemNode_UnknownKindSoftWarning(t *testing.T) { + var buf bytes.Buffer + text, ok := WarnSystemNode(&buf, SystemNodeInfo{ + Path: "/totally/unexpected/node", + Kind: SystemNodeUnknown, + }) + if !ok { + t.Fatalf("ok = false, want true (soft warning)") + } + if !strings.Contains(text, "does not recognize") { + t.Errorf("text missing soft-warning header: %q", text) + } + // Should NOT contain the platform-specific hint, since the + // classifier doesn't know which package manager the user has. + if strings.Contains(text, "brew upgrade") { + t.Errorf("unknown-kind warning should not assume brew: %q", text) + } +} + +func TestWarnSystemNode_WritesToWriter(t *testing.T) { + // Use io.Discard as a sanity check that the function actually + // writes something rather than just returning a string. + written := captureWriter(func(w io.Writer) { + _, _ = WarnSystemNode(w, SystemNodeInfo{ + Path: "/usr/bin/node", + Kind: SystemNodeOSPackage, + }) + }) + if written == "" { + t.Error("WarnSystemNode wrote nothing to the writer") + } +} + +// --- helpers --------------------------------------------------------------- + +// fakeManager is a minimal Manager implementation that returns +// whatever Name() was given. We only need Name() for the +// manager-override path; every other method is required by the +// interface but never called by the system-node tests, so they +// return zero values. +type fakeManager struct{ name string } + +func (f fakeManager) Name() string { return f.name } +func (f fakeManager) Detect() bool { return true } +func (f fakeManager) Version() (string, error) { return "0.0.0-test", nil } +func (f fakeManager) ListInstalled() ([]semver.Version, error) { return nil, nil } +func (f fakeManager) Install(semver.Version) error { return nil } +func (f fakeManager) Uninstall(semver.Version) error { return nil } +func (f fakeManager) Use(semver.Version) error { return nil } +func (f fakeManager) SetDefault(semver.Version) error { return nil } +func (f fakeManager) GlobalNpmPrefix(semver.Version) (string, error) { return "", nil } + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func captureWriter(fn func(io.Writer)) string { + var buf bytes.Buffer + fn(&buf) + return buf.String() +} + +// --- smoke test: ensure the production whichNode seam still +// compiles and reads first-line output correctly when invoked +// against a fake script. + +func TestWhichNode_DefaultsToLookPath(t *testing.T) { + // Sanity-check the production whichNode seam (which uses + // exec.LookPath under the hood): look up `node` on PATH and + // assert it returns a non-empty absolute path. We skip rather + // than fail when `node` is absent — minimal CI images and the + // `go test` host may not have node installed. + if runtime.GOOS == "windows" { + t.Skip("path semantics differ on windows; the resolution path is exercised in CI") + } + p, err := whichNode(context.Background()) + if err != nil { + t.Skipf("whichNode failed (no `node` on PATH?): %v", err) + } + if p == "" { + t.Skip("whichNode returned empty (no `node` on PATH)") + } + if !filepath.IsAbs(p) { + t.Errorf("whichNode path is not absolute: %q", p) + } +}