From 97be792a4410d8f121e03a1f81f60c48cbfdee2c Mon Sep 17 00:00:00 2001 From: Amit Dahan Date: Thu, 6 Jul 2023 10:01:44 +0300 Subject: [PATCH] experimental: support `package.json#engines` for `use` and `install` (#839) Co-authored-by: Gal Schlezinger --- .changeset/warm-parrots-drive.md | 5 ++ docs/commands.md | 91 ++++++++++++++++++++++++++++ e2e/__snapshots__/basic.test.ts.snap | 82 +++++++++++++++++++++++++ e2e/basic.test.ts | 28 +++++++++ e2e/env.test.ts | 1 + e2e/shellcode/shells.ts | 2 +- e2e/shellcode/shells/cmdEnv.ts | 4 +- src/commands/env.rs | 1 + src/config.rs | 17 ++++++ src/main.rs | 1 + src/package_json.rs | 19 ++++++ src/user_version.rs | 3 + src/user_version_reader.rs | 2 +- src/version_files.rs | 59 +++++++++++++----- 14 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 .changeset/warm-parrots-drive.md create mode 100644 src/package_json.rs diff --git a/.changeset/warm-parrots-drive.md b/.changeset/warm-parrots-drive.md new file mode 100644 index 000000000..3830587a7 --- /dev/null +++ b/.changeset/warm-parrots-drive.md @@ -0,0 +1,5 @@ +--- +"fnm": minor +--- + +Support resolving `engines.node` field via experimental `--resolve-engines` flag diff --git a/docs/commands.md b/docs/commands.md index 078c59459..00a98d3de 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,6 +59,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') @@ -112,6 +119,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -162,6 +176,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -222,6 +243,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -282,6 +310,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -347,6 +382,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -402,6 +444,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -459,6 +508,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -513,6 +569,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -569,6 +632,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -619,6 +689,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -681,6 +758,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` @@ -737,6 +821,13 @@ Options: [env: FNM_COREPACK_ENABLED] + --resolve-engines + Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + Experimental: This feature is subject to change. + Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + + [env: FNM_RESOLVE_ENGINES] + -h, --help Print help (see a summary with '-h') ``` diff --git a/e2e/__snapshots__/basic.test.ts.snap b/e2e/__snapshots__/basic.test.ts.snap index bfc43fc38..7b5d77efb 100644 --- a/e2e/__snapshots__/basic.test.ts.snap +++ b/e2e/__snapshots__/basic.test.ts.snap @@ -42,6 +42,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then fi" `; +exports[`Bash package.json engines.node with semver range: Bash 1`] = ` +"set -e +eval "$(fnm env --resolve-engines)" +fnm install +fnm use +if [ "$(node --version)" != "v6.17.0" ]; then + echo "Expected node version to be v6.17.0. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash package.json engines.node: Bash 1`] = ` +"set -e +eval "$(fnm env --resolve-engines)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + exports[`Bash resolves partial semver: Bash 1`] = ` "set -e eval "$(fnm env)" @@ -118,6 +140,28 @@ if test "$____test____" != "v8.11.3" end" `; +exports[`Fish package.json engines.node with semver range: Fish 1`] = ` +"fnm env --resolve-engines | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v6.17.0" + echo "Expected node version to be v6.17.0. Got $____test____" + exit 1 +end" +`; + +exports[`Fish package.json engines.node: Fish 1`] = ` +"fnm env --resolve-engines | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + exports[`Fish resolves partial semver: Fish 1`] = ` "fnm env | source fnm install 6 @@ -182,6 +226,22 @@ fnm use v8.11.3 if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" `; +exports[`PowerShell package.json engines.node with semver range: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env --resolve-engines | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v6.17.0" ) { echo "Expected node version to be v6.17.0. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell package.json engines.node: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env --resolve-engines | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + exports[`PowerShell resolves partial semver: PowerShell 1`] = ` "$ErrorActionPreference = "Stop" fnm env | Out-String | Invoke-Expression @@ -249,6 +309,28 @@ if [ "$(node --version)" != "v8.11.3" ]; then fi" `; +exports[`Zsh package.json engines.node with semver range: Zsh 1`] = ` +"set -e +eval "$(fnm env --resolve-engines)" +fnm install +fnm use +if [ "$(node --version)" != "v6.17.0" ]; then + echo "Expected node version to be v6.17.0. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh package.json engines.node: Zsh 1`] = ` +"set -e +eval "$(fnm env --resolve-engines)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + exports[`Zsh resolves partial semver: Zsh 1`] = ` "set -e eval "$(fnm env)" diff --git a/e2e/basic.test.ts b/e2e/basic.test.ts index d269c5ffb..17040d0b0 100644 --- a/e2e/basic.test.ts +++ b/e2e/basic.test.ts @@ -40,6 +40,34 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) { .execute(shell) }) + test(`package.json engines.node`, async () => { + await writeFile( + join(testCwd(), "package.json"), + JSON.stringify({ engines: { node: "8.11.3" } }) + ) + await script(shell) + .then(shell.env({ resolveEngines: true })) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`package.json engines.node with semver range`, async () => { + await writeFile( + join(testCwd(), "package.json"), + JSON.stringify({ engines: { node: "^6 < 6.17.1" } }) + ) + await script(shell) + .then(shell.env({ resolveEngines: true })) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v6.17.0")) + .takeSnapshot(shell) + .execute(shell) + }) + test(`use on cd`, async () => { await mkdir(join(testCwd(), "subdir"), { recursive: true }) await writeFile(join(testCwd(), "subdir", ".node-version"), "v12.22.12") diff --git a/e2e/env.test.ts b/e2e/env.test.ts index 95fe48c38..4c056c8e6 100644 --- a/e2e/env.test.ts +++ b/e2e/env.test.ts @@ -26,6 +26,7 @@ for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) { FNM_LOGLEVEL: "info", FNM_MULTISHELL_PATH: expect.any(String), FNM_NODE_DIST_MIRROR: expect.any(String), + FNM_RESOLVE_ENGINES: "false", FNM_COREPACK_ENABLED: "false", FNM_VERSION_FILE_STRATEGY: "local", }) diff --git a/e2e/shellcode/shells.ts b/e2e/shellcode/shells.ts index 10fd0affa..0fc3705d2 100644 --- a/e2e/shellcode/shells.ts +++ b/e2e/shellcode/shells.ts @@ -61,7 +61,7 @@ export const PowerShell = { ...define({ binaryName: () => "pwsh", forceFile: ".ps1", - currentlySupported: () => true, + currentlySupported: () => process.platform === "win32", name: () => "PowerShell", launchArgs: () => ["-NoProfile"], escapeText: (x) => x, diff --git a/e2e/shellcode/shells/cmdEnv.ts b/e2e/shellcode/shells/cmdEnv.ts index 82564ec8f..5edbf5ae8 100644 --- a/e2e/shellcode/shells/cmdEnv.ts +++ b/e2e/shellcode/shells/cmdEnv.ts @@ -4,16 +4,18 @@ type EnvConfig = { useOnCd: boolean logLevel: string corepackEnabled: boolean + resolveEngines: boolean } export type HasEnv = { env(cfg: Partial): ScriptLine } function stringify(envConfig: Partial = {}) { - const { useOnCd, logLevel, corepackEnabled } = envConfig + const { useOnCd, logLevel, corepackEnabled, resolveEngines } = envConfig return [ `fnm env`, useOnCd && "--use-on-cd", logLevel && `--log-level=${logLevel}`, corepackEnabled && "--corepack-enabled", + resolveEngines && `--resolve-engines`, ] .filter(Boolean) .join(" ") diff --git a/src/commands/env.rs b/src/commands/env.rs index 29f56aa0a..d8cf2b325 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -94,6 +94,7 @@ impl Command for Env { "FNM_COREPACK_ENABLED", config.corepack_enabled().to_string(), ), + ("FNM_RESOLVE_ENGINES", config.resolve_engines().to_string()), ("FNM_ARCH", config.arch.to_string()), ]); diff --git a/src/config.rs b/src/config.rs index 5b71ced86..1c653993e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,6 +75,18 @@ pub struct FnmConfig { hide_env_values = true )] corepack_enabled: bool, + + /// Resolve `engines.node` field in `package.json` whenever a `.node-version` or `.nvmrc` file is not present. + /// Experimental: This feature is subject to change. + /// Note: `engines.node` can be any semver range, with the latest satisfying version being resolved. + #[clap( + long, + env = "FNM_RESOLVE_ENGINES", + global = true, + hide_env_values = true, + verbatim_doc_comment + )] + resolve_engines: bool, } impl Default for FnmConfig { @@ -87,6 +99,7 @@ impl Default for FnmConfig { arch: Arch::default(), version_file_strategy: VersionFileStrategy::default(), corepack_enabled: false, + resolve_engines: false, } } } @@ -100,6 +113,10 @@ impl FnmConfig { self.corepack_enabled } + pub fn resolve_engines(&self) -> bool { + self.resolve_engines + } + pub fn multishell_path(&self) -> Option<&std::path::Path> { match &self.multishell_path { None => None, diff --git a/src/main.rs b/src/main.rs index 18cc24ebb..cbc4f0fb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod fs; mod http; mod installed_versions; mod lts; +mod package_json; mod path_ext; mod remote_node_index; mod shell; diff --git a/src/package_json.rs b/src/package_json.rs new file mode 100644 index 000000000..285badd8b --- /dev/null +++ b/src/package_json.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default)] +struct EnginesField { + node: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct PackageJson { + engines: Option, +} + +impl PackageJson { + pub fn node_range(&self) -> Option<&node_semver::Range> { + self.engines + .as_ref() + .and_then(|engines| engines.node.as_ref()) + } +} diff --git a/src/user_version.rs b/src/user_version.rs index c66d1f1b9..52c69a12f 100644 --- a/src/user_version.rs +++ b/src/user_version.rs @@ -5,6 +5,7 @@ use std::str::FromStr; pub enum UserVersion { OnlyMajor(u64), MajorMinor(u64, u64), + SemverRange(node_semver::Range), Full(Version), } @@ -41,6 +42,7 @@ impl UserVersion { } } } + (Self::SemverRange(range), Version::Semver(semver)) => semver.satisfies(range), (_, Version::Bypassed | Version::Lts(_) | Version::Alias(_) | Version::Latest) => false, (Self::OnlyMajor(major), Version::Semver(other)) => *major == other.major, (Self::MajorMinor(major, minor), Version::Semver(other)) => { @@ -59,6 +61,7 @@ impl std::fmt::Display for UserVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Full(x) => x.fmt(f), + Self::SemverRange(x) => x.fmt(f), Self::OnlyMajor(major) => write!(f, "v{major}.x.x"), Self::MajorMinor(major, minor) => write!(f, "v{major}.{minor}.x"), } diff --git a/src/user_version_reader.rs b/src/user_version_reader.rs index cc109ea07..4fc8a8fab 100644 --- a/src/user_version_reader.rs +++ b/src/user_version_reader.rs @@ -14,7 +14,7 @@ impl UserVersionReader { pub fn into_user_version(self, config: &FnmConfig) -> Option { match self { Self::Direct(uv) => Some(uv), - Self::Path(pathbuf) if pathbuf.is_file() => get_user_version_for_file(pathbuf), + Self::Path(pathbuf) if pathbuf.is_file() => get_user_version_for_file(pathbuf, config), Self::Path(pathbuf) => get_user_version_for_directory(pathbuf, config), } } diff --git a/src/version_files.rs b/src/version_files.rs index f002a6867..59c54adb0 100644 --- a/src/version_files.rs +++ b/src/version_files.rs @@ -1,5 +1,6 @@ use crate::config::FnmConfig; use crate::default_version; +use crate::package_json::PackageJson; use crate::user_version::UserVersion; use crate::version_file_strategy::VersionFileStrategy; use encoding_rs_io::DecodeReaderBytes; @@ -8,28 +9,30 @@ use std::io::Read; use std::path::Path; use std::str::FromStr; -const PATH_PARTS: [&str; 2] = [".nvmrc", ".node-version"]; +const PATH_PARTS: [&str; 3] = [".nvmrc", ".node-version", "package.json"]; pub fn get_user_version_for_directory( path: impl AsRef, config: &FnmConfig, ) -> Option { match config.version_file_strategy() { - VersionFileStrategy::Local => get_user_version_for_single_directory(path), - VersionFileStrategy::Recursive => { - get_user_version_for_directory_recursive(path).or_else(|| { + VersionFileStrategy::Local => get_user_version_for_single_directory(path, config), + VersionFileStrategy::Recursive => get_user_version_for_directory_recursive(path, config) + .or_else(|| { info!("Did not find anything recursively. Falling back to default alias."); default_version::find_default_version(config).map(UserVersion::Full) - }) - } + }), } } -fn get_user_version_for_directory_recursive(path: impl AsRef) -> Option { +fn get_user_version_for_directory_recursive( + path: impl AsRef, + config: &FnmConfig, +) -> Option { let mut current_path = Some(path.as_ref()); while let Some(child_path) = current_path { - if let Some(version) = get_user_version_for_single_directory(child_path) { + if let Some(version) = get_user_version_for_single_directory(child_path, config) { return Some(version); } @@ -39,7 +42,10 @@ fn get_user_version_for_directory_recursive(path: impl AsRef) -> Option) -> Option { +fn get_user_version_for_single_directory( + path: impl AsRef, + config: &FnmConfig, +) -> Option { let path = path.as_ref(); for path_part in &PATH_PARTS { @@ -49,7 +55,7 @@ pub fn get_user_version_for_single_directory(path: impl AsRef) -> Option) -> Option) -> Option { +pub fn get_user_version_for_file( + path: impl AsRef, + config: &FnmConfig, +) -> Option { + let is_pkg_json = match path.as_ref().file_name() { + Some(name) => name == "package.json", + None => false, + }; let file = std::fs::File::open(path).ok()?; - let version = { + let file = { let mut reader = DecodeReaderBytes::new(file); let mut version = String::new(); reader.read_to_string(&mut version).map(|_| version) }; - match version { - Err(err) => { + match (file, is_pkg_json, config.resolve_engines()) { + (_, true, false) => None, + (Err(err), _, _) => { info!("Can't read file: {}", err); None } - Ok(version) => { - info!("Found string {:?} in version file", version); + (Ok(version), false, _) => { + info!("Found string {:?} in version file", version); UserVersion::from_str(version.trim()).ok() } + (Ok(pkg_json), true, true) => { + let pkg_json = serde_json::from_str::(&pkg_json).ok(); + let range: Option = + pkg_json.as_ref().and_then(PackageJson::node_range).cloned(); + + if let Some(range) = range { + info!("Found package.json with {:?} in engines.node field", range); + Some(UserVersion::SemverRange(range)) + } else { + info!("No engines.node range found in package.json"); + None + } + } } }