From 327a1ef0f21fd2e3d2f95a8fdc3cfeb450a12bd6 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Thu, 12 Feb 2026 13:46:25 -0500 Subject: [PATCH 01/12] feat: add --reinstall-packages-from flag to fnm install Copies global npm packages from a specified installed Node version to a newly installed version. Resolves the source version's global packages via `npm ls`, filters out builtins (npm, corepack), and installs them on the target version. Closes #620 Refs #703, #481 --- .changeset/shiny-ducks-reinstall.md | 5 + docs/commands.md | 6 + .../reinstall-packages-from.test.ts.snap | 67 +++++ e2e/reinstall-packages-from.test.ts | 94 +++++++ src/commands/install.rs | 261 ++++++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 .changeset/shiny-ducks-reinstall.md create mode 100644 e2e/__snapshots__/reinstall-packages-from.test.ts.snap create mode 100644 e2e/reinstall-packages-from.test.ts diff --git a/.changeset/shiny-ducks-reinstall.md b/.changeset/shiny-ducks-reinstall.md new file mode 100644 index 000000000..32e83542e --- /dev/null +++ b/.changeset/shiny-ducks-reinstall.md @@ -0,0 +1,5 @@ +--- +"fnm": minor +--- + +Added `--reinstall-packages-from` flag to `fnm install`. When specified, global npm packages from the given Node version are automatically reinstalled on the newly installed version. Analogous to nvm's `--reinstall-packages-from` flag. diff --git a/docs/commands.md b/docs/commands.md index c5340a5e5..4d4209c57 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -265,6 +265,12 @@ Options: --use Use the installed version immediately after installation + --reinstall-packages-from + After installing, reinstall global npm packages from the specified + Node version. Packages are installed with their current versions. + The source version must already be installed. + Analogous to nvm's --reinstall-packages-from flag. + --arch Override the architecture of the installed Node binary. Defaults to arch of fnm binary diff --git a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap new file mode 100644 index 000000000..defa1a6c9 --- /dev/null +++ b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bash errors when source version is not installed: Bash 1`] = ` +"set -e +eval "$(fnm env --log-level=error)" +(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1) | grep 'Version v18.20.0 is not installed' || (echo "Expected output to contain 'Version v18.20.0 is not installed'" && exit 1)" +`; + +exports[`Bash reinstall packages from another version: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +fnm use v18.20.0 +npm install -g is-odd +(npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1) +__out__="$(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1)" +echo "$__out__" | grep 'is-odd@' || (echo "Expected output to contain 'is-odd@'" && exit 1) +if echo "$__out__" | grep -q ' - npm@'; then + echo "Expected output to not contain 'npm@'" + exit 1 +fi +if echo "$__out__" | grep -q ' - corepack@'; then + echo "Expected output to not contain 'corepack@'" + exit 1 +fi +echo "$__out__" | grep 'Successfully reinstalled' || (echo "Expected output to contain 'Successfully reinstalled'" && exit 1) + +fnm use v20.11.0 +(npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1)" +`; + +exports[`Bash source has no global packages: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +(fnm install v20.11.0 --reinstall-packages-from=v18.20.0) | grep 'No global packages found in' || (echo "Expected output to contain 'No global packages found in'" && exit 1)" +`; + +exports[`PowerShell errors when source version is not installed: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env --log-level=error | Out-String | Invoke-Expression +$($__out__ = $(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1 | Select-String 'Version v18.20.0 is not installed'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" +`; + +exports[`PowerShell reinstall packages from another version: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install v18.20.0 +fnm use v18.20.0 +npm install -g is-odd +$($__out__ = $(npm list -g --depth=0 | Select-String 'is-odd'); if ($__out__ -eq $null) { exit 1 } else { $__out__ }) +$__out__ = fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1 | Out-String +if ($__out__ -notmatch "is-odd@") { exit 1 } +if ($__out__ -match " - npm@") { exit 1 } +if ($__out__ -match " - corepack@") { exit 1 } +if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } + +fnm use v20.11.0 +$($__out__ = $(npm list -g --depth=0 | Select-String 'is-odd'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" +`; + +exports[`PowerShell source has no global packages: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install v18.20.0 +$($__out__ = $(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 | Select-String 'No global packages found in'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" +`; diff --git a/e2e/reinstall-packages-from.test.ts b/e2e/reinstall-packages-from.test.ts new file mode 100644 index 000000000..60c7d5506 --- /dev/null +++ b/e2e/reinstall-packages-from.test.ts @@ -0,0 +1,94 @@ +import getStderr from "./shellcode/get-stderr.js" +import { script } from "./shellcode/script.js" +import { Bash, PowerShell } from "./shellcode/shells.js" +import describe from "./describe.js" + +const SOURCE_VERSION = "v18.20.0" +const TARGET_VERSION = "v20.11.0" + +for (const shell of [Bash, PowerShell]) { + describe(shell, () => { + test(`reinstall packages from another version`, async () => { + const installTargetWithReinstall = + shell === Bash + ? `__out__="$(fnm install ${TARGET_VERSION} --reinstall-packages-from=${SOURCE_VERSION} 2>&1)" +echo "$__out__" | grep 'is-odd@' || (echo "Expected output to contain 'is-odd@'" && exit 1) +if echo "$__out__" | grep -q ' - npm@'; then + echo "Expected output to not contain 'npm@'" + exit 1 +fi +if echo "$__out__" | grep -q ' - corepack@'; then + echo "Expected output to not contain 'corepack@'" + exit 1 +fi +echo "$__out__" | grep 'Successfully reinstalled' || (echo "Expected output to contain 'Successfully reinstalled'" && exit 1) +` + : `$__out__ = fnm install ${TARGET_VERSION} --reinstall-packages-from=${SOURCE_VERSION} 2>&1 | Out-String +if ($__out__ -notmatch "is-odd@") { exit 1 } +if ($__out__ -match " - npm@") { exit 1 } +if ($__out__ -match " - corepack@") { exit 1 } +if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } +` + + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install", SOURCE_VERSION])) + .then(shell.call("fnm", ["use", SOURCE_VERSION])) + .then(shell.call("npm", ["install", "-g", "is-odd"])) + .then( + shell.scriptOutputContains( + shell.call("npm", ["list", "-g", "--depth=0"]), + "'is-odd'" + ) + ) + .then(installTargetWithReinstall) + .then(shell.call("fnm", ["use", TARGET_VERSION])) + .then( + shell.scriptOutputContains( + shell.call("npm", ["list", "-g", "--depth=0"]), + "'is-odd'" + ) + ) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`errors when source version is not installed`, async () => { + await script(shell) + .then(shell.env({ logLevel: "error" })) + .then( + shell.scriptOutputContains( + getStderr( + shell.call("fnm", [ + "install", + TARGET_VERSION, + `--reinstall-packages-from=${SOURCE_VERSION}`, + ]) + ), + "'Version v18.20.0 is not installed'", + ) + ) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`source has no global packages`, async () => { + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install", SOURCE_VERSION])) + .then( + shell.scriptOutputContains( + shell.call("fnm", [ + "install", + TARGET_VERSION, + `--reinstall-packages-from=${SOURCE_VERSION}`, + ]), + "'No global packages found in'", + ) + ) + .takeSnapshot(shell) + .execute(shell) + }) + }) +} + diff --git a/src/commands/install.rs b/src/commands/install.rs index e51ac0b8b..b44e4b497 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -4,6 +4,7 @@ use crate::alias::create_alias; use crate::arch::get_safe_arch; use crate::config::FnmConfig; use crate::downloader::{install_node_dist, Error as DownloaderError}; +use crate::installed_versions; use crate::lts::LtsType; use crate::outln; use crate::progress::ProgressConfig; @@ -38,6 +39,11 @@ pub struct Install { /// Use the installed version immediately after installation #[clap(long)] pub r#use: bool, + + /// Reinstall global packages from a specified Node version after installing. + /// Analogous to nvm's --reinstall-packages-from flag. + #[clap(long, value_name = "version")] + pub reinstall_packages_from: Option, } impl Install { @@ -73,6 +79,7 @@ impl Command for Install { let current_dir = std::env::current_dir().unwrap(); let show_progress = self.progress.enabled(config); let use_installed = self.r#use; + let reinstall_packages_from = self.reinstall_packages_from.clone(); let current_version = self .version()? @@ -172,6 +179,10 @@ impl Command for Install { enable_corepack(&version, config)?; } + if let Some(source_version_str) = reinstall_packages_from { + reinstall_packages_from_version(&source_version_str, &version, config)?; + } + if use_installed { use_installed_version(&version, config)?; } @@ -205,6 +216,196 @@ fn enable_corepack(version: &Version, config: &FnmConfig) -> Result<(), Error> { Ok(()) } +fn reinstall_packages_from_version( + source_version_str: &UserVersion, + target_version: &Version, + config: &FnmConfig, +) -> Result<(), Error> { + let all_versions = installed_versions::list(config.installations_dir()).map_err(|source| { + Error::ReinstallPackagesError { + source: Box::new(source), + } + })?; + let source_version = source_version_str + .to_version(&all_versions, config) + .ok_or_else(|| Error::ReinstallPackagesFromVersionNotInstalled { + version: source_version_str.clone(), + })? + .clone(); + + let packages = list_global_packages(&source_version, config)?; + let source_version_display = format!("Node {source_version}"); + if packages.is_empty() { + outln!( + config, + Info, + "No global packages found in {}.", + source_version_display.cyan() + ); + return Ok(()); + } + + outln!( + config, + Info, + "Reinstalling global packages from {}...", + source_version_display.cyan() + ); + for package in &packages { + outln!(config, Info, " - {}", package); + } + reinstall_packages(&packages, target_version, config)?; + outln!( + config, + Info, + "Successfully reinstalled {} packages.", + packages.len() + ); + + Ok(()) +} + +fn list_global_packages(version: &Version, config: &FnmConfig) -> Result, Error> { + use std::process::Command as StdCommand; + + let npm_path = if cfg!(windows) { + version.installation_path(config).join("npm.cmd") + } else { + version.installation_path(config).join("bin").join("npm") + }; + + let bin_dir = if cfg!(windows) { + version.installation_path(config) + } else { + version.installation_path(config).join("bin") + }; + + let path_env = + prepend_to_path_env(bin_dir).map_err(|source| Error::ReinstallPackagesError { + source: Box::new(source), + })?; + + let output = StdCommand::new(&npm_path) + .args([ + "ls", + "--global", + "--parseable", + "--long", + "--loglevel=error", + ]) + .env("PATH", path_env) + .output() + .map_err(|source| Error::ReinstallPackagesError { + source: Box::new(source), + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() && stdout.trim().is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::ReinstallPackagesError { + source: std::io::Error::other(format!( + "npm ls exited with {:?}: {}", + output.status, + stderr.trim() + )) + .into(), + }); + } + + Ok(parse_npm_ls_global_parseable_long_output(&stdout)) +} + +fn reinstall_packages( + packages: &[String], + target_version: &Version, + config: &FnmConfig, +) -> Result<(), Error> { + use std::process::{Command as StdCommand, Stdio}; + + if packages.is_empty() { + return Ok(()); + } + + let npm_path = if cfg!(windows) { + target_version.installation_path(config).join("npm.cmd") + } else { + target_version + .installation_path(config) + .join("bin") + .join("npm") + }; + + let bin_dir = if cfg!(windows) { + target_version.installation_path(config) + } else { + target_version.installation_path(config).join("bin") + }; + + let path_env = + prepend_to_path_env(bin_dir).map_err(|source| Error::ReinstallPackagesError { + source: Box::new(source), + })?; + + let status = StdCommand::new(&npm_path) + .args(["install", "--global"]) + .args(packages) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .env("PATH", path_env) + .status() + .map_err(|source| Error::ReinstallPackagesError { + source: Box::new(source), + })?; + + if !status.success() { + return Err(Error::ReinstallPackagesError { + source: std::io::Error::other(format!("npm install exited with {status:?}")).into(), + }); + } + + Ok(()) +} + +fn prepend_to_path_env( + bin_dir: std::path::PathBuf, +) -> Result { + let mut paths: Vec = std::env::var_os("PATH") + .map(|paths_env| std::env::split_paths(&paths_env).collect()) + .unwrap_or_default(); + paths.insert(0, bin_dir); + std::env::join_paths(paths) +} + +fn parse_npm_ls_global_parseable_long_output(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + + let (_path, package_spec) = line.trim_end_matches(':').rsplit_once(':')?; + let package_spec = package_spec.trim(); + if package_spec.is_empty() { + return None; + } + + if package_spec.starts_with("npm@") || package_spec.starts_with("corepack@") { + return None; + } + + let version_separator_index = package_spec.rfind('@')?; + if version_separator_index == 0 || version_separator_index == package_spec.len() - 1 { + return None; + } + + Some(package_spec.to_string()) + }) + .collect() +} + fn use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> { Use { version: Some(UserVersionReader::Direct(UserVersion::Full( @@ -255,6 +456,13 @@ pub enum Error { UninstallableVersion { version: Version }, #[error("Too many versions provided. Please don't use --lts with a version string.")] TooManyVersionsProvided, + #[error("Version {version} is not installed. Install it first with 'fnm install {version}'.")] + ReinstallPackagesFromVersionNotInstalled { version: UserVersion }, + #[error("Failed to reinstall packages: {source}")] + ReinstallPackagesError { + #[source] + source: Box, + }, } #[cfg(test)] @@ -275,6 +483,7 @@ mod tests { latest: false, progress: ProgressConfig::Never, r#use: false, + reinstall_packages_from: None, } .apply(&config) .expect("Can't install"); @@ -302,6 +511,7 @@ mod tests { latest: true, progress: ProgressConfig::Never, r#use: false, + reinstall_packages_from: None, } .apply(&config) .expect("Can't install"); @@ -319,4 +529,55 @@ mod tests { .unwrap() .exists()); } + + #[test] + fn test_parse_npm_ls_global_parseable_long_output() { + let output = r" +/path/to/lib +/path/to/node_modules/typescript:typescript@5.4.2: +/path/to/node_modules/eslint:eslint@9.0.0: +C:\Users\me\node_modules\prettier:prettier@3.2.5: +/path/to/node_modules/@openai/codex:@openai/codex@0.99.0: + "; + + let result = parse_npm_ls_global_parseable_long_output(output); + assert_eq!( + result, + vec![ + "typescript@5.4.2".to_string(), + "eslint@9.0.0".to_string(), + "prettier@3.2.5".to_string(), + "@openai/codex@0.99.0".to_string(), + ] + ); + } + + #[test] + fn test_parse_npm_ls_global_parseable_long_output_filters_builtins() { + let output = r" +/path/to/node_modules/npm:npm@10.0.0: +/path/to/node_modules/corepack:corepack@0.28.0: +/path/to/node_modules/is-odd:is-odd@3.0.1: + "; + + let result = parse_npm_ls_global_parseable_long_output(output); + assert_eq!(result, vec!["is-odd@3.0.1".to_string()]); + } + + #[test] + fn test_parse_npm_ls_global_parseable_long_output_empty() { + let result = parse_npm_ls_global_parseable_long_output(""); + assert_eq!(result, Vec::::new()); + } + + #[test] + fn test_parse_npm_ls_global_parseable_long_output_skips_malformed_lines() { + let output = r" +this is not parseable output +/path/to/node_modules/is-odd:is-odd@3.0.1: + "; + + let result = parse_npm_ls_global_parseable_long_output(output); + assert_eq!(result, vec!["is-odd@3.0.1".to_string()]); + } } From 2c3fad35754824e3bfedb01a6002a73630c543ee Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 18:38:33 -0400 Subject: [PATCH 02/12] fix: skip reinstall-packages-from when source and target versions are the same Avoids a wasteful no-op reinstall cycle when a user passes the same version to --reinstall-packages-from as the version being installed. Adds an e2e test proving the guard works. --- .../reinstall-packages-from.test.ts.snap | 14 ++++++++ e2e/reinstall-packages-from.test.ts | 33 ++++++++++++++----- src/commands/install.rs | 10 ++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap index defa1a6c9..df503a261 100644 --- a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap +++ b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap @@ -29,6 +29,13 @@ fnm use v20.11.0 (npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1)" `; +exports[`Bash skips reinstall when source and target are the same version: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +(fnm install v18.20.0 --reinstall-packages-from=v18.20.0) | grep 'Skipping package reinstallation' || (echo "Expected output to contain 'Skipping package reinstallation'" && exit 1)" +`; + exports[`Bash source has no global packages: Bash 1`] = ` "set -e eval "$(fnm env)" @@ -59,6 +66,13 @@ fnm use v20.11.0 $($__out__ = $(npm list -g --depth=0 | Select-String 'is-odd'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" `; +exports[`PowerShell skips reinstall when source and target are the same version: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install v18.20.0 +$($__out__ = $(fnm install v18.20.0 --reinstall-packages-from=v18.20.0 | Select-String 'Skipping package reinstallation'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" +`; + exports[`PowerShell source has no global packages: PowerShell 1`] = ` "$ErrorActionPreference = "Stop" fnm env | Out-String | Invoke-Expression diff --git a/e2e/reinstall-packages-from.test.ts b/e2e/reinstall-packages-from.test.ts index 60c7d5506..b10fe51db 100644 --- a/e2e/reinstall-packages-from.test.ts +++ b/e2e/reinstall-packages-from.test.ts @@ -38,16 +38,16 @@ if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } .then( shell.scriptOutputContains( shell.call("npm", ["list", "-g", "--depth=0"]), - "'is-odd'" - ) + "'is-odd'", + ), ) .then(installTargetWithReinstall) .then(shell.call("fnm", ["use", TARGET_VERSION])) .then( shell.scriptOutputContains( shell.call("npm", ["list", "-g", "--depth=0"]), - "'is-odd'" - ) + "'is-odd'", + ), ) .takeSnapshot(shell) .execute(shell) @@ -63,10 +63,28 @@ if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } "install", TARGET_VERSION, `--reinstall-packages-from=${SOURCE_VERSION}`, - ]) + ]), ), "'Version v18.20.0 is not installed'", - ) + ), + ) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`skips reinstall when source and target are the same version`, async () => { + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install", SOURCE_VERSION])) + .then( + shell.scriptOutputContains( + shell.call("fnm", [ + "install", + SOURCE_VERSION, + `--reinstall-packages-from=${SOURCE_VERSION}`, + ]), + "'Skipping package reinstallation'", + ), ) .takeSnapshot(shell) .execute(shell) @@ -84,11 +102,10 @@ if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } `--reinstall-packages-from=${SOURCE_VERSION}`, ]), "'No global packages found in'", - ) + ), ) .takeSnapshot(shell) .execute(shell) }) }) } - diff --git a/src/commands/install.rs b/src/commands/install.rs index 17950624d..b6c68f953 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -233,6 +233,16 @@ fn reinstall_packages_from_version( })? .clone(); + if source_version == *target_version { + outln!( + config, + Info, + "Source and target versions are the same ({}). Skipping package reinstallation.", + format!("Node {source_version}").cyan() + ); + return Ok(()); + } + let packages = list_global_packages(&source_version, config)?; let source_version_display = format!("Node {source_version}"); if packages.is_empty() { From 6eecf2e85bbef89875c773b46658afef6ec3b352 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 18:39:05 -0400 Subject: [PATCH 03/12] refactor: extract npm_env_for_version helper to reduce duplication The npm path and PATH env setup was duplicated between list_global_packages and reinstall_packages. --- src/commands/install.rs | 52 ++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index b6c68f953..f99df35d1 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -275,25 +275,31 @@ fn reinstall_packages_from_version( Ok(()) } -fn list_global_packages(version: &Version, config: &FnmConfig) -> Result, Error> { - use std::process::Command as StdCommand; - - let npm_path = if cfg!(windows) { - version.installation_path(config).join("npm.cmd") - } else { - version.installation_path(config).join("bin").join("npm") - }; - - let bin_dir = if cfg!(windows) { - version.installation_path(config) +/// Returns the npm binary path and a PATH env value with the version's bin dir prepended. +fn npm_env_for_version( + version: &Version, + config: &FnmConfig, +) -> Result<(std::path::PathBuf, std::ffi::OsString), Error> { + let installation_path = version.installation_path(config); + let (npm_path, bin_dir) = if cfg!(windows) { + (installation_path.join("npm.cmd"), installation_path) } else { - version.installation_path(config).join("bin") + ( + installation_path.join("bin").join("npm"), + installation_path.join("bin"), + ) }; - let path_env = prepend_to_path_env(bin_dir).map_err(|source| Error::ReinstallPackagesError { source: Box::new(source), })?; + Ok((npm_path, path_env)) +} + +fn list_global_packages(version: &Version, config: &FnmConfig) -> Result, Error> { + use std::process::Command as StdCommand; + + let (npm_path, path_env) = npm_env_for_version(version, config)?; let output = StdCommand::new(&npm_path) .args([ @@ -336,25 +342,7 @@ fn reinstall_packages( return Ok(()); } - let npm_path = if cfg!(windows) { - target_version.installation_path(config).join("npm.cmd") - } else { - target_version - .installation_path(config) - .join("bin") - .join("npm") - }; - - let bin_dir = if cfg!(windows) { - target_version.installation_path(config) - } else { - target_version.installation_path(config).join("bin") - }; - - let path_env = - prepend_to_path_env(bin_dir).map_err(|source| Error::ReinstallPackagesError { - source: Box::new(source), - })?; + let (npm_path, path_env) = npm_env_for_version(target_version, config)?; let status = StdCommand::new(&npm_path) .args(["install", "--global"]) From 57056575c6759655f8d472defb774601f5d3c340 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 18:39:26 -0400 Subject: [PATCH 04/12] fix: add Send + Sync bounds to ReinstallPackagesError source --- src/commands/install.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index f99df35d1..da1bbd90e 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -460,7 +460,7 @@ pub enum Error { #[error("Failed to reinstall packages: {source}")] ReinstallPackagesError { #[source] - source: Box, + source: Box, }, } From 90f28ee6788a81fd28211ab0640981ebbc827ee3 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 18:40:04 -0400 Subject: [PATCH 05/12] fix: warn when npm ls exits non-zero with partial output Also adds a clarifying comment on the scoped package '@' filter logic. --- src/commands/install.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index da1bbd90e..0c39de0d2 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -14,7 +14,7 @@ use crate::user_version_reader::UserVersionReader; use crate::version::Version; use crate::version_files::get_user_version_for_directory; use colored::Colorize; -use log::debug; +use log::{debug, warn}; use thiserror::Error; #[derive(clap::Parser, Debug, Default)] @@ -328,6 +328,13 @@ fn list_global_packages(version: &Version, config: &FnmConfig) -> Result Vec { return None; } + // rfind('@') returns the last '@'. For scoped packages like "@scope/pkg@1.0", + // position 0 is the scope prefix, not a version separator — skip those. let version_separator_index = package_spec.rfind('@')?; if version_separator_index == 0 || version_separator_index == package_spec.len() - 1 { return None; From 734650a66881fa90c0d4948cc2210ac8fc23d677 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 18:41:54 -0400 Subject: [PATCH 06/12] test: expand reinstall-packages-from e2e tests to cover Zsh and Fish Extracts the multi-assertion output verification into a helper function with shell-specific implementations for Bash/Zsh, Fish, and PowerShell. --- .../reinstall-packages-from.test.ts.snap | 76 +++++++++++++++++++ e2e/reinstall-packages-from.test.ts | 45 +++++++---- 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap index df503a261..7f6bccab9 100644 --- a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap +++ b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap @@ -43,6 +43,39 @@ fnm install v18.20.0 (fnm install v20.11.0 --reinstall-packages-from=v18.20.0) | grep 'No global packages found in' || (echo "Expected output to contain 'No global packages found in'" && exit 1)" `; +exports[`Fish errors when source version is not installed: Fish 1`] = ` +"fnm env --log-level=error | source +begin; fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1; end | grep 'Version v18.20.0 is not installed'; or echo "Expected output to contain 'Version v18.20.0 is not installed'" && exit 1" +`; + +exports[`Fish reinstall packages from another version: Fish 1`] = ` +"fnm env | source +fnm install v18.20.0 +fnm use v18.20.0 +npm install -g is-odd +begin; npm list -g --depth=0; end | grep 'is-odd'; or echo "Expected output to contain 'is-odd'" && exit 1 +set __out__ (fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1) +echo $__out__ | grep 'is-odd@'; or begin; echo "Expected output to contain 'is-odd@'"; exit 1; end +echo $__out__ | grep ' - npm@'; and begin; echo "Expected output to not contain 'npm@'"; exit 1; end +echo $__out__ | grep ' - corepack@'; and begin; echo "Expected output to not contain 'corepack@'"; exit 1; end +echo $__out__ | grep 'Successfully reinstalled'; or begin; echo "Expected output to contain 'Successfully reinstalled'"; exit 1; end + +fnm use v20.11.0 +begin; npm list -g --depth=0; end | grep 'is-odd'; or echo "Expected output to contain 'is-odd'" && exit 1" +`; + +exports[`Fish skips reinstall when source and target are the same version: Fish 1`] = ` +"fnm env | source +fnm install v18.20.0 +begin; fnm install v18.20.0 --reinstall-packages-from=v18.20.0; end | grep 'Skipping package reinstallation'; or echo "Expected output to contain 'Skipping package reinstallation'" && exit 1" +`; + +exports[`Fish source has no global packages: Fish 1`] = ` +"fnm env | source +fnm install v18.20.0 +begin; fnm install v20.11.0 --reinstall-packages-from=v18.20.0; end | grep 'No global packages found in'; or echo "Expected output to contain 'No global packages found in'" && exit 1" +`; + exports[`PowerShell errors when source version is not installed: PowerShell 1`] = ` "$ErrorActionPreference = "Stop" fnm env --log-level=error | Out-String | Invoke-Expression @@ -79,3 +112,46 @@ fnm env | Out-String | Invoke-Expression fnm install v18.20.0 $($__out__ = $(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 | Select-String 'No global packages found in'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" `; + +exports[`Zsh errors when source version is not installed: Zsh 1`] = ` +"set -e +eval "$(fnm env --log-level=error)" +(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1) | grep 'Version v18.20.0 is not installed' || (echo "Expected output to contain 'Version v18.20.0 is not installed'" && exit 1)" +`; + +exports[`Zsh reinstall packages from another version: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +fnm use v18.20.0 +npm install -g is-odd +(npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1) +__out__="$(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1)" +echo "$__out__" | grep 'is-odd@' || (echo "Expected output to contain 'is-odd@'" && exit 1) +if echo "$__out__" | grep -q ' - npm@'; then + echo "Expected output to not contain 'npm@'" + exit 1 +fi +if echo "$__out__" | grep -q ' - corepack@'; then + echo "Expected output to not contain 'corepack@'" + exit 1 +fi +echo "$__out__" | grep 'Successfully reinstalled' || (echo "Expected output to contain 'Successfully reinstalled'" && exit 1) + +fnm use v20.11.0 +(npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1)" +`; + +exports[`Zsh skips reinstall when source and target are the same version: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +(fnm install v18.20.0 --reinstall-packages-from=v18.20.0) | grep 'Skipping package reinstallation' || (echo "Expected output to contain 'Skipping package reinstallation'" && exit 1)" +`; + +exports[`Zsh source has no global packages: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install v18.20.0 +(fnm install v20.11.0 --reinstall-packages-from=v18.20.0) | grep 'No global packages found in' || (echo "Expected output to contain 'No global packages found in'" && exit 1)" +`; diff --git a/e2e/reinstall-packages-from.test.ts b/e2e/reinstall-packages-from.test.ts index b10fe51db..147f52c27 100644 --- a/e2e/reinstall-packages-from.test.ts +++ b/e2e/reinstall-packages-from.test.ts @@ -1,17 +1,36 @@ import getStderr from "./shellcode/get-stderr.js" import { script } from "./shellcode/script.js" -import { Bash, PowerShell } from "./shellcode/shells.js" +import { Bash, Fish, PowerShell, Zsh } from "./shellcode/shells.js" import describe from "./describe.js" const SOURCE_VERSION = "v18.20.0" const TARGET_VERSION = "v20.11.0" -for (const shell of [Bash, PowerShell]) { - describe(shell, () => { - test(`reinstall packages from another version`, async () => { - const installTargetWithReinstall = - shell === Bash - ? `__out__="$(fnm install ${TARGET_VERSION} --reinstall-packages-from=${SOURCE_VERSION} 2>&1)" +function captureAndVerifyReinstallOutput( + shell: typeof Bash | typeof Zsh | typeof Fish | typeof PowerShell, +): string { + const installCmd = `fnm install ${TARGET_VERSION} --reinstall-packages-from=${SOURCE_VERSION}` + + if (shell === PowerShell) { + return `$__out__ = ${installCmd} 2>&1 | Out-String +if ($__out__ -notmatch "is-odd@") { exit 1 } +if ($__out__ -match " - npm@") { exit 1 } +if ($__out__ -match " - corepack@") { exit 1 } +if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } +` + } + + if (shell === Fish) { + return `set __out__ (${installCmd} 2>&1) +echo $__out__ | grep 'is-odd@'; or begin; echo "Expected output to contain 'is-odd@'"; exit 1; end +echo $__out__ | grep ' - npm@'; and begin; echo "Expected output to not contain 'npm@'"; exit 1; end +echo $__out__ | grep ' - corepack@'; and begin; echo "Expected output to not contain 'corepack@'"; exit 1; end +echo $__out__ | grep 'Successfully reinstalled'; or begin; echo "Expected output to contain 'Successfully reinstalled'"; exit 1; end +` + } + + // Bash and Zsh share syntax + return `__out__="$(${installCmd} 2>&1)" echo "$__out__" | grep 'is-odd@' || (echo "Expected output to contain 'is-odd@'" && exit 1) if echo "$__out__" | grep -q ' - npm@'; then echo "Expected output to not contain 'npm@'" @@ -23,13 +42,11 @@ if echo "$__out__" | grep -q ' - corepack@'; then fi echo "$__out__" | grep 'Successfully reinstalled' || (echo "Expected output to contain 'Successfully reinstalled'" && exit 1) ` - : `$__out__ = fnm install ${TARGET_VERSION} --reinstall-packages-from=${SOURCE_VERSION} 2>&1 | Out-String -if ($__out__ -notmatch "is-odd@") { exit 1 } -if ($__out__ -match " - npm@") { exit 1 } -if ($__out__ -match " - corepack@") { exit 1 } -if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } -` +} +for (const shell of [Bash, Zsh, Fish, PowerShell]) { + describe(shell, () => { + test(`reinstall packages from another version`, async () => { await script(shell) .then(shell.env({})) .then(shell.call("fnm", ["install", SOURCE_VERSION])) @@ -41,7 +58,7 @@ if ($__out__ -notmatch "Successfully reinstalled") { exit 1 } "'is-odd'", ), ) - .then(installTargetWithReinstall) + .then(captureAndVerifyReinstallOutput(shell)) .then(shell.call("fnm", ["use", TARGET_VERSION])) .then( shell.scriptOutputContains( From 780231b5d0bed870a1e5b7bccb594d0bbe99ada4 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 21:09:48 -0400 Subject: [PATCH 07/12] refactor: use filesystem scan for reinstall package discovery Follow up on --reinstall-packages-from by listing global packages from the installed Node directory instead of calling npm ls. This avoids npm output and exit-code quirks while preserving behavior for scoped packages and npm/corepack filtering. --- .changeset/soft-bears-smell.md | 5 + src/commands/install.rs | 134 ++-------------------- src/global_packages.rs | 196 +++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 210 insertions(+), 126 deletions(-) create mode 100644 .changeset/soft-bears-smell.md create mode 100644 src/global_packages.rs diff --git a/.changeset/soft-bears-smell.md b/.changeset/soft-bears-smell.md new file mode 100644 index 000000000..bbc45de4e --- /dev/null +++ b/.changeset/soft-bears-smell.md @@ -0,0 +1,5 @@ +--- +"fnm": patch +--- + +Refine `--reinstall-packages-from` by discovering global packages from the installed Node directory instead of `npm ls`, improving determinism and avoiding npm CLI output and exit-code edge cases. diff --git a/src/commands/install.rs b/src/commands/install.rs index 0c39de0d2..dccabedcf 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -4,6 +4,7 @@ use crate::alias::create_alias; use crate::arch::get_safe_arch; use crate::config::FnmConfig; use crate::downloader::{install_node_dist, Error as DownloaderError}; +use crate::global_packages; use crate::installed_versions; use crate::lts::LtsType; use crate::outln; @@ -14,7 +15,7 @@ use crate::user_version_reader::UserVersionReader; use crate::version::Version; use crate::version_files::get_user_version_for_directory; use colored::Colorize; -use log::{debug, warn}; +use log::debug; use thiserror::Error; #[derive(clap::Parser, Debug, Default)] @@ -243,7 +244,12 @@ fn reinstall_packages_from_version( return Ok(()); } - let packages = list_global_packages(&source_version, config)?; + let packages = + global_packages::list_for_version(&source_version, config).map_err(|source| { + Error::ReinstallPackagesError { + source: Box::new(source), + } + })?; let source_version_display = format!("Node {source_version}"); if packages.is_empty() { outln!( @@ -296,48 +302,6 @@ fn npm_env_for_version( Ok((npm_path, path_env)) } -fn list_global_packages(version: &Version, config: &FnmConfig) -> Result, Error> { - use std::process::Command as StdCommand; - - let (npm_path, path_env) = npm_env_for_version(version, config)?; - - let output = StdCommand::new(&npm_path) - .args([ - "ls", - "--global", - "--parseable", - "--long", - "--loglevel=error", - ]) - .env("PATH", path_env) - .output() - .map_err(|source| Error::ReinstallPackagesError { - source: Box::new(source), - })?; - - let stdout = String::from_utf8_lossy(&output.stdout); - if !output.status.success() && stdout.trim().is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Error::ReinstallPackagesError { - source: std::io::Error::other(format!( - "npm ls exited with {:?}: {}", - output.status, - stderr.trim() - )) - .into(), - }); - } - - if !output.status.success() { - warn!( - "npm ls exited with {:?} but produced output; proceeding with partial package list", - output.status - ); - } - - Ok(parse_npm_ls_global_parseable_long_output(&stdout)) -} - fn reinstall_packages( packages: &[String], target_version: &Version, @@ -382,37 +346,6 @@ fn prepend_to_path_env( std::env::join_paths(paths) } -fn parse_npm_ls_global_parseable_long_output(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() { - return None; - } - - let (_path, package_spec) = line.trim_end_matches(':').rsplit_once(':')?; - let package_spec = package_spec.trim(); - if package_spec.is_empty() { - return None; - } - - if package_spec.starts_with("npm@") || package_spec.starts_with("corepack@") { - return None; - } - - // rfind('@') returns the last '@'. For scoped packages like "@scope/pkg@1.0", - // position 0 is the scope prefix, not a version separator — skip those. - let version_separator_index = package_spec.rfind('@')?; - if version_separator_index == 0 || version_separator_index == package_spec.len() - 1 { - return None; - } - - Some(package_spec.to_string()) - }) - .collect() -} - fn use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> { Use { version: Some(UserVersionReader::Direct(UserVersion::Full( @@ -537,55 +470,4 @@ mod tests { .unwrap() .exists()); } - - #[test] - fn test_parse_npm_ls_global_parseable_long_output() { - let output = r" -/path/to/lib -/path/to/node_modules/typescript:typescript@5.4.2: -/path/to/node_modules/eslint:eslint@9.0.0: -C:\Users\me\node_modules\prettier:prettier@3.2.5: -/path/to/node_modules/@openai/codex:@openai/codex@0.99.0: - "; - - let result = parse_npm_ls_global_parseable_long_output(output); - assert_eq!( - result, - vec![ - "typescript@5.4.2".to_string(), - "eslint@9.0.0".to_string(), - "prettier@3.2.5".to_string(), - "@openai/codex@0.99.0".to_string(), - ] - ); - } - - #[test] - fn test_parse_npm_ls_global_parseable_long_output_filters_builtins() { - let output = r" -/path/to/node_modules/npm:npm@10.0.0: -/path/to/node_modules/corepack:corepack@0.28.0: -/path/to/node_modules/is-odd:is-odd@3.0.1: - "; - - let result = parse_npm_ls_global_parseable_long_output(output); - assert_eq!(result, vec!["is-odd@3.0.1".to_string()]); - } - - #[test] - fn test_parse_npm_ls_global_parseable_long_output_empty() { - let result = parse_npm_ls_global_parseable_long_output(""); - assert_eq!(result, Vec::::new()); - } - - #[test] - fn test_parse_npm_ls_global_parseable_long_output_skips_malformed_lines() { - let output = r" -this is not parseable output -/path/to/node_modules/is-odd:is-odd@3.0.1: - "; - - let result = parse_npm_ls_global_parseable_long_output(output); - assert_eq!(result, vec!["is-odd@3.0.1".to_string()]); - } } diff --git a/src/global_packages.rs b/src/global_packages.rs new file mode 100644 index 000000000..98e082c86 --- /dev/null +++ b/src/global_packages.rs @@ -0,0 +1,196 @@ +use crate::config::FnmConfig; +use crate::version::Version; +use log::warn; +use std::path::Path; + +#[derive(serde::Deserialize)] +struct NpmPackageManifest { + name: String, + version: String, +} + +pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Result> { + let node_modules_dir = node_modules_dir_for_version(version, config); + if !node_modules_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut packages = Vec::new(); + for entry in std::fs::read_dir(&node_modules_dir)? { + let entry = entry?; + let path = entry.path(); + let package_name = entry.file_name(); + let package_name = package_name.to_string_lossy(); + + if package_name.starts_with('@') { + for scoped_entry in std::fs::read_dir(&path)? { + let scoped_entry = scoped_entry?; + if let Some(spec) = package_spec_from_dir(&scoped_entry.path())? { + packages.push(spec); + } + } + continue; + } + + if let Some(spec) = package_spec_from_dir(&path)? { + packages.push(spec); + } + } + + packages.sort_unstable(); + packages.dedup(); + + Ok(packages) +} + +pub fn node_modules_dir_for_version(version: &Version, config: &FnmConfig) -> std::path::PathBuf { + let installation_path = version.installation_path(config); + if cfg!(windows) { + installation_path.join("node_modules") + } else { + installation_path.join("lib").join("node_modules") + } +} + +fn package_spec_from_dir(package_dir: &Path) -> std::io::Result> { + if !package_dir.is_dir() { + return Ok(None); + } + + let manifest_path = package_dir.join("package.json"); + if !manifest_path.is_file() { + return Ok(None); + } + + let manifest = std::fs::read_to_string(&manifest_path)?; + let package: NpmPackageManifest = match serde_json::from_str(&manifest) { + Ok(package) => package, + Err(source) => { + warn!( + "Failed to parse {}: {source}", + manifest_path.to_string_lossy() + ); + return Ok(None); + } + }; + + if package.name == "npm" || package.name == "corepack" { + return Ok(None); + } + + if package.name.trim().is_empty() || package.version.trim().is_empty() { + warn!( + "Skipping package with missing name/version in {}", + manifest_path.to_string_lossy() + ); + return Ok(None); + } + + Ok(Some(format!("{}@{}", package.name, package.version))) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn write_global_package( + config: &FnmConfig, + version: &Version, + package_path: &str, + manifest: &str, + ) { + let package_dir = node_modules_dir_for_version(version, config).join(package_path); + std::fs::create_dir_all(&package_dir).unwrap(); + std::fs::write(package_dir.join("package.json"), manifest).unwrap(); + } + + #[test] + fn test_node_modules_dir_for_version() { + let config = FnmConfig::default(); + let version = Version::parse("20.11.0").unwrap(); + + let global_dir = node_modules_dir_for_version(&version, &config); + if cfg!(windows) { + assert!(global_dir.ends_with("installation/node_modules")); + } else { + assert!(global_dir.ends_with("installation/lib/node_modules")); + } + } + + #[test] + fn test_list_for_version_reads_unscoped_and_scoped_packages() { + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + write_global_package( + &config, + &version, + "is-odd", + r#"{"name":"is-odd","version":"3.0.1"}"#, + ); + write_global_package( + &config, + &version, + "@scope/tool", + r#"{"name":"@scope/tool","version":"1.2.3"}"#, + ); + write_global_package( + &config, + &version, + "npm", + r#"{"name":"npm","version":"10.0.0"}"#, + ); + write_global_package( + &config, + &version, + "corepack", + r#"{"name":"corepack","version":"0.28.0"}"#, + ); + + let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, vec!["@scope/tool@1.2.3", "is-odd@3.0.1"]); + } + + #[test] + fn test_list_for_version_returns_empty_when_node_modules_is_missing() { + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, Vec::::new()); + } + + #[test] + fn test_list_for_version_skips_malformed_package_json() { + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + write_global_package(&config, &version, "is-even", "this is not valid json"); + write_global_package( + &config, + &version, + "is-odd", + r#"{"name":"is-odd","version":"3.0.1"}"#, + ); + + let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, vec!["is-odd@3.0.1"]); + } + + #[test] + fn test_list_for_version_skips_directories_without_package_json() { + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + let package_dir = node_modules_dir_for_version(&version, &config).join("left-pad"); + std::fs::create_dir_all(package_dir).unwrap(); + + let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, Vec::::new()); + } +} diff --git a/src/main.rs b/src/main.rs index 6ab10eab5..0ae9990e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod current_version; mod directory_portal; mod downloader; mod fs; +mod global_packages; mod http; mod installed_versions; mod lts; From 9644e46bdab788fecef1cb114957e2bd70daddff Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Fri, 17 Apr 2026 21:29:36 -0400 Subject: [PATCH 08/12] fix: skip symlinked globals in package reinstallation Ignore symlinked entries when scanning global packages for --reinstall-packages-from and log a warning for skipped items. This avoids trying to reinstall locally linked packages that may not exist in registries. --- .changeset/three-bugs-wave.md | 5 +++ src/global_packages.rs | 61 ++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .changeset/three-bugs-wave.md diff --git a/.changeset/three-bugs-wave.md b/.changeset/three-bugs-wave.md new file mode 100644 index 000000000..9d582a820 --- /dev/null +++ b/.changeset/three-bugs-wave.md @@ -0,0 +1,5 @@ +--- +"fnm": patch +--- + +Skip symlinked global package entries when using `--reinstall-packages-from` and warn when they are ignored, to avoid reinstall attempts for locally linked packages. diff --git a/src/global_packages.rs b/src/global_packages.rs index 98e082c86..be401230c 100644 --- a/src/global_packages.rs +++ b/src/global_packages.rs @@ -22,10 +22,33 @@ pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Resul let package_name = entry.file_name(); let package_name = package_name.to_string_lossy(); + if is_symlink(&path)? { + warn!( + "Skipping symlinked global package entry {} at {}", + package_name, + path.to_string_lossy() + ); + continue; + } + if package_name.starts_with('@') { for scoped_entry in std::fs::read_dir(&path)? { let scoped_entry = scoped_entry?; - if let Some(spec) = package_spec_from_dir(&scoped_entry.path())? { + let scoped_path = scoped_entry.path(); + let scoped_package_name = scoped_entry.file_name(); + let scoped_package_name = scoped_package_name.to_string_lossy(); + + if is_symlink(&scoped_path)? { + warn!( + "Skipping symlinked global package entry {}/{} at {}", + package_name, + scoped_package_name, + scoped_path.to_string_lossy() + ); + continue; + } + + if let Some(spec) = package_spec_from_dir(&scoped_path)? { packages.push(spec); } } @@ -43,6 +66,10 @@ pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Resul Ok(packages) } +fn is_symlink(path: &Path) -> std::io::Result { + Ok(std::fs::symlink_metadata(path)?.file_type().is_symlink()) +} + pub fn node_modules_dir_for_version(version: &Version, config: &FnmConfig) -> std::path::PathBuf { let installation_path = version.installation_path(config); if cfg!(windows) { @@ -193,4 +220,36 @@ mod tests { let result = list_for_version(&version, &config).unwrap(); assert_eq!(result, Vec::::new()); } + + #[cfg(unix)] + #[test] + fn test_list_for_version_skips_symlinked_packages() { + use std::os::unix::fs::symlink; + + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + write_global_package( + &config, + &version, + "is-odd", + r#"{"name":"is-odd","version":"3.0.1"}"#, + ); + + let external_package = base_dir.path().join("linked-package"); + std::fs::create_dir_all(&external_package).unwrap(); + std::fs::write( + external_package.join("package.json"), + r#"{"name":"linked-only","version":"1.0.0"}"#, + ) + .unwrap(); + + let node_modules_dir = node_modules_dir_for_version(&version, &config); + std::fs::create_dir_all(&node_modules_dir).unwrap(); + symlink(&external_package, node_modules_dir.join("linked-only")).unwrap(); + + let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, vec!["is-odd@3.0.1"]); + } } From 6f10b8bf5d3da99e87b14825bb9ed17272adb0f5 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Sat, 18 Apr 2026 21:26:04 -0400 Subject: [PATCH 09/12] fix: include APPDATA npm globals in reinstall scan Windows global npm packages may live under %APPDATA%\npm\node_modules. Scan that location in addition to the Node installation path so --reinstall-packages-from consistently finds and reinstalls globals in Windows CI. --- .changeset/mean-moons-wink.md | 5 +++ src/global_packages.rs | 80 +++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/mean-moons-wink.md diff --git a/.changeset/mean-moons-wink.md b/.changeset/mean-moons-wink.md new file mode 100644 index 000000000..165ad47b1 --- /dev/null +++ b/.changeset/mean-moons-wink.md @@ -0,0 +1,5 @@ +--- +"fnm": patch +--- + +Fix `--reinstall-packages-from` on Windows by also scanning `%APPDATA%\\npm\\node_modules` for global packages. diff --git a/src/global_packages.rs b/src/global_packages.rs index be401230c..6ed387ad4 100644 --- a/src/global_packages.rs +++ b/src/global_packages.rs @@ -10,13 +10,26 @@ struct NpmPackageManifest { } pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Result> { - let node_modules_dir = node_modules_dir_for_version(version, config); + let mut packages = Vec::new(); + for node_modules_dir in node_modules_dirs_for_version(version, config) { + collect_packages_from_node_modules_dir(&node_modules_dir, &mut packages)?; + } + + packages.sort_unstable(); + packages.dedup(); + + Ok(packages) +} + +fn collect_packages_from_node_modules_dir( + node_modules_dir: &Path, + packages: &mut Vec, +) -> std::io::Result<()> { if !node_modules_dir.is_dir() { - return Ok(Vec::new()); + return Ok(()); } - let mut packages = Vec::new(); - for entry in std::fs::read_dir(&node_modules_dir)? { + for entry in std::fs::read_dir(node_modules_dir)? { let entry = entry?; let path = entry.path(); let package_name = entry.file_name(); @@ -60,16 +73,29 @@ pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Resul } } - packages.sort_unstable(); - packages.dedup(); - - Ok(packages) + Ok(()) } fn is_symlink(path: &Path) -> std::io::Result { Ok(std::fs::symlink_metadata(path)?.file_type().is_symlink()) } +fn node_modules_dirs_for_version(version: &Version, config: &FnmConfig) -> Vec { + let mut node_modules_dirs = vec![node_modules_dir_for_version(version, config)]; + + if cfg!(windows) { + if let Some(app_data) = std::env::var_os("APPDATA") { + node_modules_dirs.push( + std::path::PathBuf::from(app_data) + .join("npm") + .join("node_modules"), + ); + } + } + + node_modules_dirs +} + pub fn node_modules_dir_for_version(version: &Version, config: &FnmConfig) -> std::path::PathBuf { let installation_path = version.installation_path(config); if cfg!(windows) { @@ -252,4 +278,42 @@ mod tests { let result = list_for_version(&version, &config).unwrap(); assert_eq!(result, vec!["is-odd@3.0.1"]); } + + #[cfg(windows)] + #[test] + fn test_list_for_version_reads_packages_from_appdata_npm_node_modules() { + let base_dir = tempfile::tempdir().unwrap(); + let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); + let version = Version::parse("20.11.0").unwrap(); + + write_global_package( + &config, + &version, + "is-odd", + r#"{"name":"is-odd","version":"3.0.1"}"#, + ); + + let app_data = base_dir.path().join("appdata"); + let app_data_node_modules = app_data.join("npm").join("node_modules"); + std::fs::create_dir_all(app_data_node_modules.join("from-appdata")).unwrap(); + std::fs::write( + app_data_node_modules + .join("from-appdata") + .join("package.json"), + r#"{"name":"from-appdata","version":"1.0.0"}"#, + ) + .unwrap(); + + unsafe { + std::env::set_var("APPDATA", &app_data); + } + + let result = list_for_version(&version, &config).unwrap(); + + unsafe { + std::env::remove_var("APPDATA"); + } + + assert_eq!(result, vec!["from-appdata@1.0.0", "is-odd@3.0.1"]); + } } From e57734aed2c27234730fd56b9aa433b02034028e Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Sat, 18 Apr 2026 21:33:54 -0400 Subject: [PATCH 10/12] fix: use npm ls for Windows global package discovery Add a Windows-specific discovery path for --reinstall-packages-from using npm ls --global --depth=0 --json, with an inline rationale that npm prefix location is not reliably derivable from fnm install paths. Keep filesystem scanning for non-Windows platforms. --- .changeset/mean-moons-wink.md | 2 +- src/global_packages.rs | 141 +++++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 46 deletions(-) diff --git a/.changeset/mean-moons-wink.md b/.changeset/mean-moons-wink.md index 165ad47b1..2fbb0f727 100644 --- a/.changeset/mean-moons-wink.md +++ b/.changeset/mean-moons-wink.md @@ -2,4 +2,4 @@ "fnm": patch --- -Fix `--reinstall-packages-from` on Windows by also scanning `%APPDATA%\\npm\\node_modules` for global packages. +Use `npm ls --global --depth=0 --json` on Windows for `--reinstall-packages-from` package discovery, where global package location depends on npm prefix configuration. diff --git a/src/global_packages.rs b/src/global_packages.rs index 6ed387ad4..98fada3e8 100644 --- a/src/global_packages.rs +++ b/src/global_packages.rs @@ -1,6 +1,7 @@ use crate::config::FnmConfig; use crate::version::Version; use log::warn; +use std::collections::HashMap; use std::path::Path; #[derive(serde::Deserialize)] @@ -9,18 +10,64 @@ struct NpmPackageManifest { version: String, } +#[derive(serde::Deserialize)] +struct NpmLsRoot { + #[serde(default)] + dependencies: HashMap, +} + +#[derive(serde::Deserialize)] +struct NpmLsPackage { + version: Option, +} + pub fn list_for_version(version: &Version, config: &FnmConfig) -> std::io::Result> { - let mut packages = Vec::new(); - for node_modules_dir in node_modules_dirs_for_version(version, config) { - collect_packages_from_node_modules_dir(&node_modules_dir, &mut packages)?; + // On Windows, npm global installs are commonly resolved via npm's configured prefix + // (often under %APPDATA%\npm), which is not reliably derivable from fnm's Node + // installation path alone. Use npm ls for source-version discovery there. + if cfg!(windows) { + return list_for_version_with_npm_ls(version, config); } + let mut packages = Vec::new(); + let version_node_modules_dir = node_modules_dir_for_version(version, config); + collect_packages_from_node_modules_dir(&version_node_modules_dir, &mut packages)?; + packages.sort_unstable(); packages.dedup(); Ok(packages) } +fn list_for_version_with_npm_ls( + version: &Version, + config: &FnmConfig, +) -> std::io::Result> { + let npm_path = version.installation_path(config).join("npm.cmd"); + let output = std::process::Command::new(&npm_path) + .args(["ls", "--global", "--depth=0", "--json", "--loglevel=error"]) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() && stdout.trim().is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(std::io::Error::other(format!( + "npm ls exited with {:?}: {}", + output.status, + stderr.trim() + ))); + } + + if !output.status.success() { + warn!( + "npm ls exited with {:?} but produced output; proceeding with partial package list", + output.status + ); + } + + parse_npm_ls_global_json_output(&stdout) +} + fn collect_packages_from_node_modules_dir( node_modules_dir: &Path, packages: &mut Vec, @@ -80,20 +127,30 @@ fn is_symlink(path: &Path) -> std::io::Result { Ok(std::fs::symlink_metadata(path)?.file_type().is_symlink()) } -fn node_modules_dirs_for_version(version: &Version, config: &FnmConfig) -> Vec { - let mut node_modules_dirs = vec![node_modules_dir_for_version(version, config)]; +fn parse_npm_ls_global_json_output(stdout: &str) -> std::io::Result> { + let npm_ls: NpmLsRoot = serde_json::from_str(stdout).map_err(std::io::Error::other)?; - if cfg!(windows) { - if let Some(app_data) = std::env::var_os("APPDATA") { - node_modules_dirs.push( - std::path::PathBuf::from(app_data) - .join("npm") - .join("node_modules"), - ); - } - } + let mut packages = npm_ls + .dependencies + .into_iter() + .filter_map(|(name, package)| { + if name == "npm" || name == "corepack" { + return None; + } + + let version = package.version?; + if version.trim().is_empty() { + return None; + } + + Some(format!("{}@{}", name, version)) + }) + .collect::>(); + + packages.sort_unstable(); + packages.dedup(); - node_modules_dirs + Ok(packages) } pub fn node_modules_dir_for_version(version: &Version, config: &FnmConfig) -> std::path::PathBuf { @@ -279,41 +336,35 @@ mod tests { assert_eq!(result, vec!["is-odd@3.0.1"]); } - #[cfg(windows)] #[test] - fn test_list_for_version_reads_packages_from_appdata_npm_node_modules() { - let base_dir = tempfile::tempdir().unwrap(); - let config = FnmConfig::default().with_base_dir(Some(base_dir.path().to_path_buf())); - let version = Version::parse("20.11.0").unwrap(); - - write_global_package( - &config, - &version, - "is-odd", - r#"{"name":"is-odd","version":"3.0.1"}"#, - ); - - let app_data = base_dir.path().join("appdata"); - let app_data_node_modules = app_data.join("npm").join("node_modules"); - std::fs::create_dir_all(app_data_node_modules.join("from-appdata")).unwrap(); - std::fs::write( - app_data_node_modules - .join("from-appdata") - .join("package.json"), - r#"{"name":"from-appdata","version":"1.0.0"}"#, + fn test_parse_npm_ls_global_json_output() { + let result = parse_npm_ls_global_json_output( + r#"{ + "dependencies": { + "is-odd": { "version": "3.0.1" }, + "@scope/tool": { "version": "1.2.3" }, + "npm": { "version": "10.9.0" }, + "corepack": { "version": "0.29.4" } + } + }"#, ) .unwrap(); - unsafe { - std::env::set_var("APPDATA", &app_data); - } - - let result = list_for_version(&version, &config).unwrap(); + assert_eq!(result, vec!["@scope/tool@1.2.3", "is-odd@3.0.1"]); + } - unsafe { - std::env::remove_var("APPDATA"); - } + #[test] + fn test_parse_npm_ls_global_json_output_skips_missing_version() { + let result = parse_npm_ls_global_json_output( + r#"{ + "dependencies": { + "is-odd": { "version": "3.0.1" }, + "broken": {} + } + }"#, + ) + .unwrap(); - assert_eq!(result, vec!["from-appdata@1.0.0", "is-odd@3.0.1"]); + assert_eq!(result, vec!["is-odd@3.0.1"]); } } From 32789c1b5245b9586963462340ff843aad423733 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Sat, 18 Apr 2026 21:34:53 -0400 Subject: [PATCH 11/12] chore: consolidate reinstall-packages changesets Keep a single changeset for this PR by folding follow-up filesystem discovery, symlink handling, and Windows package discovery notes into the original reinstall-packages-from entry. --- .changeset/mean-moons-wink.md | 5 ----- .changeset/shiny-ducks-reinstall.md | 2 ++ .changeset/soft-bears-smell.md | 5 ----- .changeset/three-bugs-wave.md | 5 ----- 4 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 .changeset/mean-moons-wink.md delete mode 100644 .changeset/soft-bears-smell.md delete mode 100644 .changeset/three-bugs-wave.md diff --git a/.changeset/mean-moons-wink.md b/.changeset/mean-moons-wink.md deleted file mode 100644 index 2fbb0f727..000000000 --- a/.changeset/mean-moons-wink.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"fnm": patch ---- - -Use `npm ls --global --depth=0 --json` on Windows for `--reinstall-packages-from` package discovery, where global package location depends on npm prefix configuration. diff --git a/.changeset/shiny-ducks-reinstall.md b/.changeset/shiny-ducks-reinstall.md index 32e83542e..1e2c688f1 100644 --- a/.changeset/shiny-ducks-reinstall.md +++ b/.changeset/shiny-ducks-reinstall.md @@ -3,3 +3,5 @@ --- Added `--reinstall-packages-from` flag to `fnm install`. When specified, global npm packages from the given Node version are automatically reinstalled on the newly installed version. Analogous to nvm's `--reinstall-packages-from` flag. + +Package discovery now uses filesystem scanning on non-Windows platforms and `npm ls --global --depth=0 --json` on Windows; symlinked global package entries are skipped to avoid reinstalling locally linked packages. diff --git a/.changeset/soft-bears-smell.md b/.changeset/soft-bears-smell.md deleted file mode 100644 index bbc45de4e..000000000 --- a/.changeset/soft-bears-smell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"fnm": patch ---- - -Refine `--reinstall-packages-from` by discovering global packages from the installed Node directory instead of `npm ls`, improving determinism and avoiding npm CLI output and exit-code edge cases. diff --git a/.changeset/three-bugs-wave.md b/.changeset/three-bugs-wave.md deleted file mode 100644 index 9d582a820..000000000 --- a/.changeset/three-bugs-wave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"fnm": patch ---- - -Skip symlinked global package entries when using `--reinstall-packages-from` and warn when they are ignored, to avoid reinstall attempts for locally linked packages. From f3c174f97acf22f06db2ea93a846a8a23732a080 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Sat, 18 Apr 2026 22:31:35 -0400 Subject: [PATCH 12/12] fix: stabilize reinstall CI checks across platforms --- docs/commands.md | 22 ++++++++++++++----- .../reinstall-packages-from.test.ts.snap | 8 +++++++ e2e/reinstall-packages-from.test.ts | 20 +++++++++++++++++ src/global_packages.rs | 11 ++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index af074708a..725847ec9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -43,6 +43,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -126,6 +127,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -188,6 +190,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -265,16 +268,14 @@ Options: --use Use the installed version immediately after installation - --reinstall-packages-from - After installing, reinstall global npm packages from the specified - Node version. Packages are installed with their current versions. - The source version must already be installed. - Analogous to nvm's --reinstall-packages-from flag. - --arch Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] + + --reinstall-packages-from + Reinstall global packages from a specified Node version after installing. Analogous to nvm's --reinstall-packages-from flag --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -347,6 +348,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -424,6 +426,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -491,6 +494,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -560,6 +564,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -626,6 +631,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -694,6 +700,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -756,6 +763,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -830,6 +838,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation @@ -898,6 +907,7 @@ Options: Override the architecture of the installed Node binary. Defaults to arch of fnm binary [env: FNM_ARCH] + [possible values: x86, x64, x64-musl, x64-glibc217, arm64, armv7l, ppc64le, ppc64, s390x] --version-file-strategy A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation diff --git a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap index 7f6bccab9..ff28d0935 100644 --- a/e2e/__snapshots__/reinstall-packages-from.test.ts.snap +++ b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap @@ -9,6 +9,7 @@ eval "$(fnm env --log-level=error)" exports[`Bash reinstall packages from another version: Bash 1`] = ` "set -e eval "$(fnm env)" +npm config set prefix ./npm-global --location=user fnm install v18.20.0 fnm use v18.20.0 npm install -g is-odd @@ -32,6 +33,7 @@ fnm use v20.11.0 exports[`Bash skips reinstall when source and target are the same version: Bash 1`] = ` "set -e eval "$(fnm env)" +npm config set prefix ./npm-global --location=user fnm install v18.20.0 (fnm install v18.20.0 --reinstall-packages-from=v18.20.0) | grep 'Skipping package reinstallation' || (echo "Expected output to contain 'Skipping package reinstallation'" && exit 1)" `; @@ -50,6 +52,7 @@ begin; fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1; end | grep exports[`Fish reinstall packages from another version: Fish 1`] = ` "fnm env | source +npm config set prefix ./npm-global --location=user fnm install v18.20.0 fnm use v18.20.0 npm install -g is-odd @@ -66,6 +69,7 @@ begin; npm list -g --depth=0; end | grep 'is-odd'; or echo "Expected output to c exports[`Fish skips reinstall when source and target are the same version: Fish 1`] = ` "fnm env | source +npm config set prefix ./npm-global --location=user fnm install v18.20.0 begin; fnm install v18.20.0 --reinstall-packages-from=v18.20.0; end | grep 'Skipping package reinstallation'; or echo "Expected output to contain 'Skipping package reinstallation'" && exit 1" `; @@ -85,6 +89,7 @@ $($__out__ = $(fnm install v20.11.0 --reinstall-packages-from=v18.20.0 2>&1 | Se exports[`PowerShell reinstall packages from another version: PowerShell 1`] = ` "$ErrorActionPreference = "Stop" fnm env | Out-String | Invoke-Expression +npm config set prefix ./npm-global --location=user fnm install v18.20.0 fnm use v18.20.0 npm install -g is-odd @@ -102,6 +107,7 @@ $($__out__ = $(npm list -g --depth=0 | Select-String 'is-odd'); if ($__out__ -eq exports[`PowerShell skips reinstall when source and target are the same version: PowerShell 1`] = ` "$ErrorActionPreference = "Stop" fnm env | Out-String | Invoke-Expression +npm config set prefix ./npm-global --location=user fnm install v18.20.0 $($__out__ = $(fnm install v18.20.0 --reinstall-packages-from=v18.20.0 | Select-String 'Skipping package reinstallation'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" `; @@ -122,6 +128,7 @@ eval "$(fnm env --log-level=error)" exports[`Zsh reinstall packages from another version: Zsh 1`] = ` "set -e eval "$(fnm env)" +npm config set prefix ./npm-global --location=user fnm install v18.20.0 fnm use v18.20.0 npm install -g is-odd @@ -145,6 +152,7 @@ fnm use v20.11.0 exports[`Zsh skips reinstall when source and target are the same version: Zsh 1`] = ` "set -e eval "$(fnm env)" +npm config set prefix ./npm-global --location=user fnm install v18.20.0 (fnm install v18.20.0 --reinstall-packages-from=v18.20.0) | grep 'Skipping package reinstallation' || (echo "Expected output to contain 'Skipping package reinstallation'" && exit 1)" `; diff --git a/e2e/reinstall-packages-from.test.ts b/e2e/reinstall-packages-from.test.ts index 147f52c27..b53406bb1 100644 --- a/e2e/reinstall-packages-from.test.ts +++ b/e2e/reinstall-packages-from.test.ts @@ -3,6 +3,8 @@ import { script } from "./shellcode/script.js" import { Bash, Fish, PowerShell, Zsh } from "./shellcode/shells.js" import describe from "./describe.js" +const GLOBAL_PREFIX = "./npm-global" + const SOURCE_VERSION = "v18.20.0" const TARGET_VERSION = "v20.11.0" @@ -49,6 +51,15 @@ for (const shell of [Bash, Zsh, Fish, PowerShell]) { test(`reinstall packages from another version`, async () => { await script(shell) .then(shell.env({})) + .then( + shell.call("npm", [ + "config", + "set", + "prefix", + GLOBAL_PREFIX, + "--location=user", + ]), + ) .then(shell.call("fnm", ["install", SOURCE_VERSION])) .then(shell.call("fnm", ["use", SOURCE_VERSION])) .then(shell.call("npm", ["install", "-g", "is-odd"])) @@ -92,6 +103,15 @@ for (const shell of [Bash, Zsh, Fish, PowerShell]) { test(`skips reinstall when source and target are the same version`, async () => { await script(shell) .then(shell.env({})) + .then( + shell.call("npm", [ + "config", + "set", + "prefix", + GLOBAL_PREFIX, + "--location=user", + ]), + ) .then(shell.call("fnm", ["install", SOURCE_VERSION])) .then( shell.scriptOutputContains( diff --git a/src/global_packages.rs b/src/global_packages.rs index 98fada3e8..d6c38b9b9 100644 --- a/src/global_packages.rs +++ b/src/global_packages.rs @@ -143,7 +143,7 @@ fn parse_npm_ls_global_json_output(stdout: &str) -> std::io::Result> return None; } - Some(format!("{}@{}", name, version)) + Some(format!("{name}@{version}")) }) .collect::>(); @@ -196,7 +196,10 @@ fn package_spec_from_dir(package_dir: &Path) -> std::io::Result> return Ok(None); } - Ok(Some(format!("{}@{}", package.name, package.version))) + let name = package.name; + let version = package.version; + + Ok(Some(format!("{name}@{version}"))) } #[cfg(test)] @@ -228,6 +231,7 @@ mod tests { } } + #[cfg(not(windows))] #[test] fn test_list_for_version_reads_unscoped_and_scoped_packages() { let base_dir = tempfile::tempdir().unwrap(); @@ -263,6 +267,7 @@ mod tests { assert_eq!(result, vec!["@scope/tool@1.2.3", "is-odd@3.0.1"]); } + #[cfg(not(windows))] #[test] fn test_list_for_version_returns_empty_when_node_modules_is_missing() { let base_dir = tempfile::tempdir().unwrap(); @@ -273,6 +278,7 @@ mod tests { assert_eq!(result, Vec::::new()); } + #[cfg(not(windows))] #[test] fn test_list_for_version_skips_malformed_package_json() { let base_dir = tempfile::tempdir().unwrap(); @@ -291,6 +297,7 @@ mod tests { assert_eq!(result, vec!["is-odd@3.0.1"]); } + #[cfg(not(windows))] #[test] fn test_list_for_version_skips_directories_without_package_json() { let base_dir = tempfile::tempdir().unwrap();