diff --git a/.changeset/shy-otters-install.md b/.changeset/shy-otters-install.md new file mode 100644 index 000000000..e49f7a5c0 --- /dev/null +++ b/.changeset/shy-otters-install.md @@ -0,0 +1,6 @@ +--- +"fnm": minor +--- + +Added support for `default-packages` file. When present at `$FNM_DIR/default-packages`, packages listed in this file are automatically installed globally after every `fnm install`. Compatible with nvm's default-packages format. + diff --git a/docs/commands.md b/docs/commands.md index 3e2b4855c..76fc16f9d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -225,6 +225,8 @@ Options: ``` Install a new Node.js version +When a `default-packages` file exists at `$FNM_DIR/default-packages`, fnm will install the listed packages globally (via `npm install -g`) after each `fnm install`. + Usage: fnm install [OPTIONS] [VERSION] Arguments: diff --git a/docs/configuration.md b/docs/configuration.md index a205a4e44..12f8ec804 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,6 +52,14 @@ error: Can't find version in dotfiles. Please provide a version manually to the Runs [`corepack enable`](https://nodejs.org/api/corepack.html#enabling-the-feature) when a new version of Node.js is installed. Experimental due to the fact Corepack itself is experimental. +### `default-packages` file + +When present at `$FNM_DIR/default-packages`, fnm will automatically install packages listed in this file globally after every `fnm install` by running `npm install -g`. You can run `fnm env` to see the value of `$FNM_DIR` on your system. + +The file format is one package spec per line (supports `@version`), and lines starting with `#` are ignored. + +This is compatible with nvm's `default-packages` format. + ### `--resolve-engines` **🧪 Experimental** diff --git a/e2e/__snapshots__/default-packages.test.ts.snap b/e2e/__snapshots__/default-packages.test.ts.snap new file mode 100644 index 000000000..723924d2d --- /dev/null +++ b/e2e/__snapshots__/default-packages.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bash installs default packages: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install 18 +(fnm exec --using=18 npm list -g --depth=0) | grep 'fnm-default-packages-test' || (echo "Expected output to contain 'fnm-default-packages-test'" && exit 1)" +`; + +exports[`Bash missing default-packages file does not error: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install 18" +`; + +exports[`PowerShell installs default packages: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install 18 +$($__out__ = $(fnm exec --using=18 npm list -g --depth=0 | Select-String 'fnm-default-packages-test'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })" +`; + +exports[`PowerShell missing default-packages file does not error: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install 18" +`; diff --git a/e2e/default-packages.test.ts b/e2e/default-packages.test.ts new file mode 100644 index 000000000..3fd63ae74 --- /dev/null +++ b/e2e/default-packages.test.ts @@ -0,0 +1,56 @@ +import fs from "fs" +import path from "path" +import { script } from "./shellcode/script.js" +import { Bash, PowerShell } from "./shellcode/shells.js" +import describe from "./describe.js" +import testTmpDir from "./shellcode/test-tmp-dir.js" + +for (const shell of [Bash, PowerShell]) { + describe(shell, () => { + test(`installs default packages`, async () => { + const fnmDir = path.join(testTmpDir(), "fnm") + fs.mkdirSync(fnmDir, { recursive: true }) + const pkgDir = path.join(testTmpDir(), "default-packages-pkg") + fs.mkdirSync(pkgDir, { recursive: true }) + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify( + { + name: "fnm-default-packages-test", + version: "1.0.0", + }, + null, + 2 + ) + ) + fs.writeFileSync(path.join(fnmDir, "default-packages"), `${pkgDir}\n`) + + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install", "18"])) + .then( + shell.scriptOutputContains( + shell.call("fnm", [ + "exec", + "--using=18", + "npm", + "list", + "-g", + "--depth=0", + ]), + "'fnm-default-packages-test'" + ) + ) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`missing default-packages file does not error`, async () => { + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install", "18"])) + .takeSnapshot(shell) + .execute(shell) + }) + }) +} diff --git a/src/cli.rs b/src/cli.rs index e670ff4aa..18e65b945 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,6 +14,10 @@ pub enum SubCommand { LsLocal(commands::ls_local::LsLocal), /// Install a new Node.js version + /// + /// When a `default-packages` file exists at `$FNM_DIR/default-packages`, + /// fnm will install the listed packages globally (via `npm install -g`) + /// after each `fnm install`. #[clap(name = "install", bin_name = "install", visible_aliases = &["i"])] Install(commands::install::Install), diff --git a/src/commands/install.rs b/src/commands/install.rs index 594fcff87..4b16ced6a 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -172,6 +172,8 @@ impl Command for Install { enable_corepack(&version, config)?; } + install_default_packages(&version, config)?; + if use_installed { use_installed_version(&version, config)?; } @@ -199,12 +201,84 @@ fn enable_corepack(version: &Version, config: &FnmConfig) -> Result<(), Error> { } else { corepack_path.join("bin").join("corepack") }; - super::exec::Exec::new_for_version(version, corepack_path.to_str().unwrap(), &["enable"]) + let corepack_path_str = corepack_path.to_string_lossy(); + super::exec::Exec::new_for_version(version, &corepack_path_str, &["enable"]) .apply(config) .map_err(|source| Error::CorepackError { source })?; Ok(()) } +fn parse_default_packages_file(file_path: &std::path::Path) -> Result, std::io::Error> { + let contents = match std::fs::read_to_string(file_path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(err) => return Err(err), + }; + + Ok(contents + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect()) +} + +fn install_default_packages(version: &Version, config: &FnmConfig) -> Result<(), Error> { + use std::process::{Command as StdCommand, Stdio}; + + let packages = parse_default_packages_file(&config.default_packages_file())?; + if packages.is_empty() { + return Ok(()); + } + + let npm_path = if cfg!(windows) { + version.installation_path(config).join("npm.cmd") + } else { + version.installation_path(config).join("bin").join("npm") + }; + + let bin_path = if cfg!(windows) { + version.installation_path(config) + } else { + version.installation_path(config).join("bin") + }; + + let path_env = { + 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_path); + std::env::join_paths(paths).map_err(|source| Error::DefaultPackagesError { + 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::DefaultPackagesError { + source: Box::new(source), + })?; + + if !status.success() { + return Err(Error::DefaultPackagesError { + source: std::io::Error::other(format!("npm install exited with {status:?}")).into(), + }); + } + + Ok(()) +} + fn use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> { Use { version: Some(UserVersionReader::Direct(UserVersion::Full( @@ -235,6 +309,11 @@ pub enum Error { #[from] source: super::exec::Error, }, + #[error("Can't install default packages: {source}")] + DefaultPackagesError { + #[source] + source: Box, + }, #[error(transparent)] UseError { source: Box<::Error>, @@ -320,4 +399,40 @@ mod tests { .unwrap() .exists()); } + + #[test] + fn test_parse_default_packages_file() { + let base_dir = tempfile::tempdir().unwrap(); + let file_path = base_dir.path().join("default-packages"); + std::fs::write( + &file_path, + r" +# this is a comment + +typescript +prettier@3 +@scope/name +", + ) + .unwrap(); + + let packages = parse_default_packages_file(&file_path).unwrap(); + assert_eq!( + packages, + vec![ + "typescript".to_string(), + "prettier@3".to_string(), + "@scope/name".to_string(), + ] + ); + } + + #[test] + fn test_parse_default_packages_file_missing() { + let base_dir = tempfile::tempdir().unwrap(); + let file_path = base_dir.path().join("default-packages"); + + let packages = parse_default_packages_file(&file_path).unwrap(); + assert_eq!(packages, Vec::::new()); + } } diff --git a/src/config.rs b/src/config.rs index 6d482f9ed..c552b39cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -164,6 +164,10 @@ impl FnmConfig { .ensure_exists_silently() } + pub fn default_packages_file(&self) -> std::path::PathBuf { + self.base_dir_with_default().join("default-packages") + } + pub fn multishell_storage(&self) -> std::path::PathBuf { self.directories.multishell_storage() }