diff --git a/.changeset/shiny-ducks-reinstall.md b/.changeset/shiny-ducks-reinstall.md new file mode 100644 index 000000000..1e2c688f1 --- /dev/null +++ b/.changeset/shiny-ducks-reinstall.md @@ -0,0 +1,7 @@ +--- +"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. + +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/docs/commands.md b/docs/commands.md index 3e2b4855c..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 @@ -269,6 +272,10 @@ 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] + + --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 @@ -341,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 @@ -418,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 @@ -485,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 @@ -554,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 @@ -620,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 @@ -688,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 @@ -750,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 @@ -824,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 @@ -892,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 new file mode 100644 index 000000000..ff28d0935 --- /dev/null +++ b/e2e/__snapshots__/reinstall-packages-from.test.ts.snap @@ -0,0 +1,165 @@ +// 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)" +npm config set prefix ./npm-global --location=user +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 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)" +`; + +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[`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 +npm config set prefix ./npm-global --location=user +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 +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" +`; + +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 +$($__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 +npm config set prefix ./npm-global --location=user +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 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__ })" +`; + +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__ })" +`; + +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)" +npm config set prefix ./npm-global --location=user +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)" +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)" +`; + +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 new file mode 100644 index 000000000..b53406bb1 --- /dev/null +++ b/e2e/reinstall-packages-from.test.ts @@ -0,0 +1,148 @@ +import getStderr from "./shellcode/get-stderr.js" +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" + +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@'" + 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) +` +} + +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("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"])) + .then( + shell.scriptOutputContains( + shell.call("npm", ["list", "-g", "--depth=0"]), + "'is-odd'", + ), + ) + .then(captureAndVerifyReinstallOutput(shell)) + .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(`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( + shell.call("fnm", [ + "install", + SOURCE_VERSION, + `--reinstall-packages-from=${SOURCE_VERSION}`, + ]), + "'Skipping package reinstallation'", + ), + ) + .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 594fcff87..dccabedcf 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -4,6 +4,8 @@ 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; use crate::progress::ProgressConfig; @@ -38,6 +40,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 +80,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 +180,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 +217,135 @@ 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(); + + 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 = + 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!( + 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(()) +} + +/// 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 { + ( + 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 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, path_env) = npm_env_for_version(target_version, config)?; + + 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 use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> { Use { version: Some(UserVersionReader::Direct(UserVersion::Full( @@ -256,6 +397,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)] @@ -276,6 +424,7 @@ mod tests { latest: false, progress: ProgressConfig::Never, r#use: false, + reinstall_packages_from: None, } .apply(&config) .expect("Can't install"); @@ -303,6 +452,7 @@ mod tests { latest: true, progress: ProgressConfig::Never, r#use: false, + reinstall_packages_from: None, } .apply(&config) .expect("Can't install"); diff --git a/src/global_packages.rs b/src/global_packages.rs new file mode 100644 index 000000000..d6c38b9b9 --- /dev/null +++ b/src/global_packages.rs @@ -0,0 +1,377 @@ +use crate::config::FnmConfig; +use crate::version::Version; +use log::warn; +use std::collections::HashMap; +use std::path::Path; + +#[derive(serde::Deserialize)] +struct NpmPackageManifest { + name: String, + 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> { + // 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, +) -> std::io::Result<()> { + if !node_modules_dir.is_dir() { + return Ok(()); + } + + 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 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?; + 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); + } + } + continue; + } + + if let Some(spec) = package_spec_from_dir(&path)? { + packages.push(spec); + } + } + + Ok(()) +} + +fn is_symlink(path: &Path) -> std::io::Result { + Ok(std::fs::symlink_metadata(path)?.file_type().is_symlink()) +} + +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)?; + + 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(); + + 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); + } + + let name = package.name; + let version = package.version; + + Ok(Some(format!("{name}@{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")); + } + } + + #[cfg(not(windows))] + #[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"]); + } + + #[cfg(not(windows))] + #[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()); + } + + #[cfg(not(windows))] + #[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"]); + } + + #[cfg(not(windows))] + #[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()); + } + + #[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"]); + } + + #[test] + 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(); + + assert_eq!(result, vec!["@scope/tool@1.2.3", "is-odd@3.0.1"]); + } + + #[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!["is-odd@3.0.1"]); + } +} 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;