From 7dd49048cd6cf623477329164fc659b877820b0a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Apr 2026 19:29:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(config)!:=20match=20pnpm=2011=20config=20?= =?UTF-8?q?=E2=80=94=20yaml=20for=20settings,=20.npmrc=20for=20auth=20only?= =?UTF-8?q?=20(#248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): read settings from pnpm-workspace.yaml like pnpm v11 pnpm 10+ moved the bulk of its configuration (\`storeDir\`, \`registry\`, \`lockfile\`, …) out of \`.npmrc\` into \`pnpm-workspace.yaml\`, using camelCase keys. A real pnpm-11-style project may put those overrides only in the yaml, so pacquet has to read them from there to behave correctly — and after the v11 store migration (#247) this was the last place the two tools still disagreed about where config lives. This PR makes pacquet load and layer \`pnpm-workspace.yaml\` on top of \`.npmrc\`, matching pnpm's own precedence: yaml wins over \`.npmrc\` for any key it sets; \`.npmrc\` wins over hard-coded defaults. ## How it works - New \`WorkspaceSettings\` struct in \`pacquet-npmrc\` with camelCase serde derives and \`Option\` for every field mirroring the current \`Npmrc\`: \`hoist\`, \`hoistPattern\`, \`publicHoistPattern\`, \`shamefullyHoist\`, \`storeDir\`, \`modulesDir\`, \`nodeLinker\`, \`symlink\`, \`virtualStoreDir\`, \`packageImportMethod\`, \`modulesCacheMaxAge\`, \`lockfile\`, \`preferFrozenLockfile\`, \`lockfileIncludeTarballUrl\`, \`registry\`, \`autoInstallPeers\`, \`dedupePeerDependents\`, \`strictPeerDependencies\`, \`resolvePeersFromWorkspaceRoot\`. - Non-config keys commonly present in a real pnpm-workspace.yaml (\`packages\`, \`catalog\`, \`catalogs\`, \`onlyBuiltDependencies\`, \`allowBuilds\`, …) are silently ignored — serde drops them since the struct doesn't use \`deny_unknown_fields\`. - \`WorkspaceSettings::find_and_load\` walks up from cwd looking for the nearest \`pnpm-workspace.yaml\` (same algorithm pnpm uses — the first ancestor that has one is the workspace root). - \`WorkspaceSettings::apply_to\` overlays every set field onto the mutable \`Npmrc\`, resolving relative path-valued fields (\`storeDir\`, \`modulesDir\`, \`virtualStoreDir\`) against the workspace root instead of cwd — again matching pnpm. - \`Npmrc::current\` now layers the result on top of whatever \`.npmrc\` (or the default) provided. Missing yaml or unreadable yaml is silently ignored, matching \`.npmrc\`'s best-effort behaviour. 9 new unit tests cover yaml parsing (known + unknown keys), field apply-or-skip, absolute/relative path resolution, upward directory walk, and the full \`Npmrc::current\` integration with both files present. The \`testing-utils::add_mocked_registry\` helper from #247 already writes both \`.npmrc\` and \`pnpm-workspace.yaml\` (so both tools recognise the store-dir override). With this PR, pacquet now actually uses the yaml side of that pair. * feat(config)!: stop reading non-auth settings from .npmrc, like pnpm 11 pnpm 11 narrowed \`.npmrc\` to an auth/network-only allow-list: \`registry\`, \`ca\` / \`cafile\` / \`cert\` / \`key\`, \`_auth\` / \`_authToken\` / \`_password\` / \`email\` / \`keyfile\` / \`username\`, \`https-proxy\` / \`proxy\` / \`no-proxy\` / \`http-proxy\` / \`local-address\` / \`strict-ssl\`, plus the \`@scope:registry\` and \`//host:_authToken\` dynamic patterns. Everything else (\`storeDir\`, \`lockfile\`, hoist patterns, node-linker, …) has to come from \`pnpm-workspace.yaml\` or CLI flags. Pacquet now does the same. Behaviour changes: - Writing project-structural settings to \`.npmrc\` (\`store-dir=…\`, \`lockfile=…\`, \`hoist=…\`, \`node-linker=…\`, …) no longer has any effect on the resolved config. Move them to \`pnpm-workspace.yaml\`. Since pacquet is pre-1.0 and #247 already broke the old store format, there's no migration shim. - \`.npmrc\` is still read for the auth/network subset — today that's just \`registry\`, which is all pacquet actually uses. The other auth keys are tracked in the parser so lighting up auth / proxy / TLS later doesn't require another .npmrc refactor. - Precedence is unchanged in shape: defaults → .npmrc (auth-only) → \`pnpm-workspace.yaml\`. Implementation: - New \`NpmrcAuth\` struct in \`pacquet-npmrc\` with a hand-rolled line parser (drops comments, skips malformed lines, ignores section headers). Picks \`registry\` and normalises the trailing slash — same behaviour the old \`deserialize_registry\` had. - \`Npmrc::current\` no longer runs \`serde_ini::from_str::\` on \`.npmrc\`. It starts from the caller-supplied default, applies the \`NpmrcAuth\` from the nearest .npmrc (cwd first, home second), then layers the yaml overrides from #248 on top. - Tests that used to write \`symlink=false\` / similar to .npmrc now either assert the opposite (the setting is ignored) or exercise \`registry\` instead. 5 new unit tests in \`npmrc_auth.rs\` cover registry pickup, trailing-slash normalisation, non-auth key ignoring, and malformed-line resilience. The \`Npmrc\` struct still derives \`Deserialize\` so \`Npmrc::new()\` can keep using \`serde_ini::from_str(\"\")\` as its default-producer — cleaning that up to a hand-written \`Default\` impl is separate churn. * test(cli): move store-path test off .npmrc onto pnpm-workspace.yaml `storeDir` is a project-structural setting — after pnpm 11 (and this PR's matching narrowing in pacquet) it's only honoured from `pnpm-workspace.yaml`, not `.npmrc`. The old test wrote `store-dir=foo/bar` to `.npmrc` and expected pacquet to print that as the store path, which is exactly the behaviour we just removed. Switched it to write `storeDir: foo/bar` to `pnpm-workspace.yaml` instead — same assertion, correct config source. * review: make config doc comments match what the code actually does - `NpmrcAuth`'s module doc overstated scope: only `registry` is parsed, but the old wording read as if the full pnpm auth allow-list was implemented. Rewritten to say what's handled today and what's tracked for later. - `Npmrc::current`'s doc listed auth keys (TLS / `_authToken` / scoped-registry / proxy) as if pacquet read them. It doesn't. Narrowed to 'registry only, rest ignored'. - A test comment in `workspace_yaml::tests::swallows_unknown_top_level_keys` still referenced `deny_unknown_fields` and a 'flatten catch-all', both of which I removed in an earlier commit on this branch (serde drops unknown struct fields by default). Updated to describe the actual regression the test guards against — somebody later adding `deny_unknown_fields` to `WorkspaceSettings`. --- pacquet/Cargo.lock | 1 + pacquet/crates/cli/tests/store.rs | 9 +- pacquet/crates/npmrc/Cargo.toml | 1 + pacquet/crates/npmrc/src/lib.rs | 132 ++++++++-- pacquet/crates/npmrc/src/npmrc_auth.rs | 127 +++++++++ pacquet/crates/npmrc/src/workspace_yaml.rs | 285 +++++++++++++++++++++ 6 files changed, 526 insertions(+), 29 deletions(-) create mode 100644 pacquet/crates/npmrc/src/npmrc_auth.rs create mode 100644 pacquet/crates/npmrc/src/workspace_yaml.rs diff --git a/pacquet/Cargo.lock b/pacquet/Cargo.lock index 6128e8e7d6..db292fe74d 100644 --- a/pacquet/Cargo.lock +++ b/pacquet/Cargo.lock @@ -1675,6 +1675,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_ini", + "serde_yaml", "tempfile", ] diff --git a/pacquet/crates/cli/tests/store.rs b/pacquet/crates/cli/tests/store.rs index d5df3c865d..c3ce23ce03 100644 --- a/pacquet/crates/cli/tests/store.rs +++ b/pacquet/crates/cli/tests/store.rs @@ -19,11 +19,14 @@ fn canonicalize(path: &Path) -> PathBuf { } #[test] -fn store_path_should_return_store_dir_from_npmrc() { +fn store_path_should_return_store_dir_from_pnpm_workspace_yaml() { + // `storeDir` is a project-structural setting — in pnpm 11 (and now + // pacquet) it's only honoured from `pnpm-workspace.yaml`, not `.npmrc`. let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); - eprintln!("Creating .npmrc..."); - fs::write(workspace.join(".npmrc"), "store-dir=foo/bar").expect("write to .npmrc"); + eprintln!("Creating pnpm-workspace.yaml..."); + fs::write(workspace.join("pnpm-workspace.yaml"), "storeDir: foo/bar\n") + .expect("write to pnpm-workspace.yaml"); eprintln!("Executing pacquet store path..."); let output = pacquet.with_args(["store", "path"]).output().expect("run pacquet store path"); diff --git a/pacquet/crates/npmrc/Cargo.toml b/pacquet/crates/npmrc/Cargo.toml index 98b7cff9ed..0e87b02f7b 100644 --- a/pacquet/crates/npmrc/Cargo.toml +++ b/pacquet/crates/npmrc/Cargo.toml @@ -17,6 +17,7 @@ home = { workspace = true } pipe-trait = { workspace = true } serde = { workspace = true } serde_ini = { workspace = true } +serde_yaml = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/pacquet/crates/npmrc/src/lib.rs b/pacquet/crates/npmrc/src/lib.rs index d82e1dae8e..0d530ef256 100644 --- a/pacquet/crates/npmrc/src/lib.rs +++ b/pacquet/crates/npmrc/src/lib.rs @@ -1,4 +1,6 @@ mod custom_deserializer; +mod npmrc_auth; +mod workspace_yaml; use pacquet_store_dir::StoreDir; use pipe_trait::Pipe; @@ -11,6 +13,9 @@ use crate::custom_deserializer::{ deserialize_bool, deserialize_pathbuf, deserialize_registry, deserialize_store_dir, deserialize_u64, }; +pub use workspace_yaml::{ + workspace_root_or, LoadWorkspaceYamlError, WorkspaceSettings, WORKSPACE_MANIFEST_FILENAME, +}; #[derive(Debug, Deserialize, Default, PartialEq)] #[serde(rename_all = "kebab-case")] @@ -159,9 +164,20 @@ impl Npmrc { config } - /// Try loading `.npmrc` in the current directory. - /// If fails, try in the home directory. - /// If fails again, return the default. + /// Build the runtime config by layering: + /// 1. hard-coded defaults, then + /// 2. the supported `.npmrc` subset read from the nearest `.npmrc` + /// (cwd, falling back to home), then + /// 3. the nearest `pnpm-workspace.yaml` walking up from cwd. + /// + /// Pacquet currently only applies `registry` from `.npmrc`. Other + /// `.npmrc` entries — pnpm's TLS / npm-auth / proxy / scoped-registry + /// keys, plus project-structural settings like `storeDir`, `lockfile` + /// and `hoist-pattern` — are silently ignored here. The first group + /// is tracked for future auth / proxy / TLS work; the second must + /// come from `pnpm-workspace.yaml` or CLI flags, matching pnpm 11. + /// + /// The yaml wins over `.npmrc` on any key it sets. pub fn current( current_dir: CurrentDir, home_dir: HomeDir, @@ -172,22 +188,30 @@ impl Npmrc { HomeDir: FnOnce() -> Option, Default: FnOnce() -> Npmrc, { - // TODO: this code makes no sense. - // TODO: it should have merged the settings. + let mut npmrc = default(); - let load = |dir: PathBuf| -> Option { - dir.join(".npmrc") - .pipe(fs::read_to_string) - .ok()? // TODO: should it throw error instead? - .pipe_as_ref(serde_ini::from_str) - .ok() // TODO: should it throw error instead? - }; + let cwd = current_dir().ok(); + // Read the nearest .npmrc (cwd first, home second) and apply only + // the auth/network subset. Everything else is intentionally ignored. + let auth_source = cwd + .as_ref() + .and_then(|dir| read_npmrc(dir)) + .or_else(|| home_dir().and_then(|dir| read_npmrc(&dir))); + if let Some(text) = auth_source { + crate::npmrc_auth::NpmrcAuth::from_ini(&text).apply_to(&mut npmrc); + } - current_dir() - .ok() - .and_then(load) - .or_else(|| home_dir().and_then(load)) - .unwrap_or_else(default) + // Layer pnpm-workspace.yaml overrides on top. Missing file or + // unreadable yaml is silently ignored, matching .npmrc's + // best-effort behaviour above. + if let Some(start) = cwd { + if let Ok(Some((path, settings))) = WorkspaceSettings::find_and_load(&start) { + let base_dir = path.parent().unwrap_or(&start).to_path_buf(); + settings.apply_to(&mut npmrc, &base_dir); + } + } + + npmrc } /// Persist the config data until the program terminates. @@ -196,6 +220,13 @@ impl Npmrc { } } +/// Read the text of the `.npmrc` in `dir`, returning `None` for anything +/// from "file doesn't exist" to "not valid UTF-8" — same best-effort +/// behaviour as pnpm. The caller decides which keys to honour. +fn read_npmrc(dir: &std::path::Path) -> Option { + fs::read_to_string(dir.join(".npmrc")).ok() +} + impl Default for Npmrc { fn default() -> Self { Self::new() @@ -293,15 +324,33 @@ mod tests { } #[test] - pub fn test_current_folder_for_npmrc() { + pub fn npmrc_in_current_folder_applies_registry() { let tmp = tempdir().unwrap(); - fs::write(tmp.path().join(".npmrc"), "symlink=false").expect("write to .npmrc"); + fs::write(tmp.path().join(".npmrc"), "registry=https://cwd.example") + .expect("write to .npmrc"); let config = Npmrc::current( || tmp.path().to_path_buf().pipe(Ok::<_, ()>), || unreachable!("shouldn't reach home dir"), - || unreachable!("shouldn't reach default"), + Npmrc::new, ); - assert!(!config.symlink); + assert_eq!(config.registry, "https://cwd.example/"); + } + + #[test] + pub fn non_auth_keys_in_npmrc_are_ignored() { + // pnpm 11 stopped reading project-structural settings from .npmrc. + // Writing `symlink=false` / `lockfile=true` / hoist / node-linker / + // store-dir to .npmrc should have no effect on the resolved config. + let tmp = tempdir().unwrap(); + let non_auth_ini = "symlink=false\nlockfile=true\nhoist=false\nnode-linker=hoisted\n"; + fs::write(tmp.path().join(".npmrc"), non_auth_ini).expect("write to .npmrc"); + let defaults = Npmrc::new(); + let config = + Npmrc::current(|| tmp.path().to_path_buf().pipe(Ok::<_, ()>), || None, Npmrc::new); + assert_eq!(config.symlink, defaults.symlink); + assert_eq!(config.lockfile, defaults.lockfile); + assert_eq!(config.hoist, defaults.hoist); + assert_eq!(config.node_linker, defaults.node_linker); } #[test] @@ -311,20 +360,51 @@ mod tests { fs::write(tmp.path().join(".npmrc"), b"Hello \xff World").expect("write to .npmrc"); let config = Npmrc::current(|| tmp.path().to_path_buf().pipe(Ok::<_, ()>), || None, Npmrc::new); - assert!(config.symlink); // TODO: what the hell? why succeed? + assert!(config.symlink); // default — invalid .npmrc is silently ignored } #[test] - pub fn test_current_folder_fallback_to_home() { + pub fn npmrc_in_home_folder_applies_registry() { let current_dir = tempdir().unwrap(); let home_dir = tempdir().unwrap(); - dbg!(¤t_dir, &home_dir); - fs::write(home_dir.path().join(".npmrc"), "symlink=false").expect("write to .npmrc"); + fs::write(home_dir.path().join(".npmrc"), "registry=https://home.example") + .expect("write to .npmrc"); let config = Npmrc::current( || current_dir.path().to_path_buf().pipe(Ok::<_, ()>), || home_dir.path().to_path_buf().pipe(Some), - || unreachable!("shouldn't reach home dir"), + Npmrc::new, ); + assert_eq!(config.registry, "https://home.example/"); + } + + #[test] + pub fn pnpm_workspace_yaml_registry_overrides_npmrc_registry() { + // `registry` is the one non-scope key pnpm 11 still reads from + // .npmrc (it's in RAW_AUTH_CFG_KEYS). When both files define it, + // the yaml wins, matching pnpm itself. + let tmp = tempdir().unwrap(); + fs::write(tmp.path().join(".npmrc"), "registry=https://from-npmrc.test") + .expect("write to .npmrc"); + fs::write(tmp.path().join("pnpm-workspace.yaml"), "registry: https://from-yaml.test\n") + .expect("write to pnpm-workspace.yaml"); + let config = Npmrc::current( + || tmp.path().to_path_buf().pipe(Ok::<_, ()>), + || unreachable!("shouldn't reach home dir"), + Npmrc::new, + ); + assert_eq!(config.registry, "https://from-yaml.test/"); + } + + #[test] + pub fn pnpm_workspace_yaml_found_by_walking_up() { + let tmp = tempdir().unwrap(); + let nested = tmp.path().join("packages/inner"); + fs::create_dir_all(&nested).unwrap(); + fs::write(tmp.path().join("pnpm-workspace.yaml"), "symlink: false\n") + .expect("write to pnpm-workspace.yaml"); + // No `.npmrc` anywhere, but a parent dir has `pnpm-workspace.yaml` — + // the yaml should still be applied. + let config = Npmrc::current(|| nested.clone().pipe(Ok::<_, ()>), || None, Npmrc::new); assert!(!config.symlink); } diff --git a/pacquet/crates/npmrc/src/npmrc_auth.rs b/pacquet/crates/npmrc/src/npmrc_auth.rs new file mode 100644 index 0000000000..3773a6b12e --- /dev/null +++ b/pacquet/crates/npmrc/src/npmrc_auth.rs @@ -0,0 +1,127 @@ +use crate::Npmrc; + +/// Narrow subset of `.npmrc` that pacquet currently reads. +/// +/// At the moment this parser only extracts the top-level `registry` key. +/// The rest of pnpm's `.npmrc` allow-list (TLS via `ca` / `cafile` / +/// `cert` / `key`, npm auth via `_auth` / `_authToken` / `_password` / +/// `email` / `keyfile` / `username`, proxy via `https-proxy` / `proxy` / +/// `no-proxy` / `http-proxy` / `local-address` / `strict-ssl`, plus the +/// dynamic `@scope:registry` and `//host:_authToken` patterns) is not yet +/// represented in `NpmrcAuth` and is silently ignored. +/// +/// Project-structural settings (`storeDir`, `lockfile`, hoist pattern, +/// `node-linker`, …) now live in `pnpm-workspace.yaml` and are also +/// ignored here. +#[derive(Debug, Default, PartialEq)] +pub struct NpmrcAuth { + pub registry: Option, +} + +impl NpmrcAuth { + /// Parse an `.npmrc` file's contents and pick out the auth/network keys. + /// Unknown keys are silently dropped. + /// + /// The `.npmrc` format is a tiny ini dialect: one `key=value` per line, + /// plus comments starting with `;` or `#`. We hand-parse instead of + /// `serde_ini` so unknown / malformed keys don't blow up parsing the way + /// they would with a strongly-typed deserializer. + pub fn from_ini(text: &str) -> Self { + let mut auth = NpmrcAuth::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with(';') || line.starts_with('#') { + continue; + } + // `[section]` headers aren't meaningful in .npmrc; skip them. + if line.starts_with('[') && line.ends_with(']') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + if key == "registry" { + auth.registry = Some(value.to_string()); + } + // Other auth/network keys aren't consumed yet — they'll land + // here as pacquet gains auth / proxy / TLS support. + } + auth + } + + /// Apply the parsed auth settings onto `npmrc`, leaving unset fields + /// alone and doing the same trailing-slash normalisation the ini + /// deserializer used to perform via `deserialize_registry`. + pub fn apply_to(self, npmrc: &mut Npmrc) { + if let Some(registry) = self.registry { + npmrc.registry = + if registry.ends_with('/') { registry } else { format!("{registry}/") }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn picks_up_registry_and_normalises_trailing_slash() { + let ini = "registry=https://r.example\n"; + let auth = NpmrcAuth::from_ini(ini); + assert_eq!(auth.registry.as_deref(), Some("https://r.example")); + + let mut npmrc = Npmrc::new(); + auth.apply_to(&mut npmrc); + assert_eq!(npmrc.registry, "https://r.example/"); + } + + #[test] + fn preserves_existing_trailing_slash() { + let mut npmrc = Npmrc::new(); + NpmrcAuth::from_ini("registry=https://r.example/\n").apply_to(&mut npmrc); + assert_eq!(npmrc.registry, "https://r.example/"); + } + + #[test] + fn ignores_non_auth_keys() { + // These are all project-structural settings that pnpm 11 only reads + // from pnpm-workspace.yaml now. Writing them to .npmrc should be a + // no-op. + let ini = " +store-dir=/should/not/apply +lockfile=false +hoist=false +node-linker=hoisted +"; + let npmrc_before = Npmrc::new(); + let mut npmrc = Npmrc::new(); + NpmrcAuth::from_ini(ini).apply_to(&mut npmrc); + assert_eq!(npmrc.store_dir, npmrc_before.store_dir); + assert_eq!(npmrc.lockfile, npmrc_before.lockfile); + assert_eq!(npmrc.hoist, npmrc_before.hoist); + assert_eq!(npmrc.node_linker, npmrc_before.node_linker); + } + + #[test] + fn ignores_comments_and_empty_lines() { + let ini = " +# this is a comment +; another comment + +registry=https://r.example +# trailing comment +"; + let auth = NpmrcAuth::from_ini(ini); + assert_eq!(auth.registry.as_deref(), Some("https://r.example")); + } + + #[test] + fn ignores_malformed_lines() { + let ini = "not_a_key_value\nregistry=https://r.example\n=orphan_equals\n"; + let auth = NpmrcAuth::from_ini(ini); + assert_eq!(auth.registry.as_deref(), Some("https://r.example")); + } +} diff --git a/pacquet/crates/npmrc/src/workspace_yaml.rs b/pacquet/crates/npmrc/src/workspace_yaml.rs new file mode 100644 index 0000000000..35c472afd6 --- /dev/null +++ b/pacquet/crates/npmrc/src/workspace_yaml.rs @@ -0,0 +1,285 @@ +use crate::{NodeLinker, Npmrc, PackageImportMethod}; +use pacquet_store_dir::StoreDir; +use serde::Deserialize; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +/// Settings readable from `pnpm-workspace.yaml`. +/// +/// pnpm 10+ moved the bulk of its configuration (`storeDir`, `registry`, +/// `lockfile`, …) out of `.npmrc` into `pnpm-workspace.yaml`, using +/// camelCase keys. Pacquet needs to honour these overrides so a real +/// pnpm-11-style project — where `.npmrc` may not even contain the +/// settings — works out of the box. +/// +/// Every field is `Option` because the yaml is strictly additive on top of +/// [`Npmrc`]: anything left unset falls through to whatever `.npmrc` provided +/// (or the hard-coded default). +/// +/// See for the canonical key list. +/// Non-config keys in a real pnpm-workspace.yaml (`packages`, `catalog`, +/// `catalogs`, `onlyBuiltDependencies`, `allowBuilds`, …) are silently +/// ignored — serde drops them since the struct doesn't use +/// `deny_unknown_fields`. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct WorkspaceSettings { + pub hoist: Option, + pub hoist_pattern: Option>, + pub public_hoist_pattern: Option>, + pub shamefully_hoist: Option, + pub store_dir: Option, + pub modules_dir: Option, + pub node_linker: Option, + pub symlink: Option, + pub virtual_store_dir: Option, + pub package_import_method: Option, + pub modules_cache_max_age: Option, + pub lockfile: Option, + pub prefer_frozen_lockfile: Option, + pub lockfile_include_tarball_url: Option, + pub registry: Option, + pub auto_install_peers: Option, + pub dedupe_peer_dependents: Option, + pub strict_peer_dependencies: Option, + pub resolve_peers_from_workspace_root: Option, +} + +/// Basename of the file pnpm reads; exported for test use. +pub const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml"; + +/// Error when reading `pnpm-workspace.yaml`. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadWorkspaceYamlError { + ReadFile(io::Error), + ParseYaml(serde_yaml::Error), +} + +impl WorkspaceSettings { + /// Walk up from `start_dir` looking for a `pnpm-workspace.yaml`. Returns + /// `Ok(None)` if none is found before reaching the filesystem root. + /// + /// Mirrors pnpm's behaviour in + /// [`loadNpmrcFiles.ts`](https://github.com/pnpm/pnpm/blob/main/config/reader/src/loadNpmrcFiles.ts) + /// — the first ancestor containing a `pnpm-workspace.yaml` is the + /// workspace root, and its config applies. + pub fn find_and_load( + start_dir: &Path, + ) -> Result, LoadWorkspaceYamlError> { + let Some(path) = find_workspace_manifest(start_dir) else { + return Ok(None); + }; + let text = fs::read_to_string(&path).map_err(LoadWorkspaceYamlError::ReadFile)?; + let settings: WorkspaceSettings = + serde_yaml::from_str(&text).map_err(LoadWorkspaceYamlError::ParseYaml)?; + Ok(Some((path, settings))) + } + + /// Apply every set field onto `npmrc`, leaving unset ones untouched. + /// + /// Path-valued fields (`store_dir`, `modules_dir`, `virtual_store_dir`) + /// are resolved against `base_dir` if relative — mirroring `.npmrc`'s + /// resolve-against-cwd behaviour but anchored at the workspace root + /// where the yaml was found, which is what pnpm does. + pub fn apply_to(self, npmrc: &mut Npmrc, base_dir: &Path) { + if let Some(v) = self.hoist { + npmrc.hoist = v; + } + if let Some(v) = self.hoist_pattern { + npmrc.hoist_pattern = v; + } + if let Some(v) = self.public_hoist_pattern { + npmrc.public_hoist_pattern = v; + } + if let Some(v) = self.shamefully_hoist { + npmrc.shamefully_hoist = v; + } + if let Some(v) = self.store_dir { + npmrc.store_dir = StoreDir::from(resolve(base_dir, &v)); + } + if let Some(v) = self.modules_dir { + npmrc.modules_dir = resolve(base_dir, &v); + } + if let Some(v) = self.node_linker { + npmrc.node_linker = v; + } + if let Some(v) = self.symlink { + npmrc.symlink = v; + } + if let Some(v) = self.virtual_store_dir { + npmrc.virtual_store_dir = resolve(base_dir, &v); + } + if let Some(v) = self.package_import_method { + npmrc.package_import_method = v; + } + if let Some(v) = self.modules_cache_max_age { + npmrc.modules_cache_max_age = v; + } + if let Some(v) = self.lockfile { + npmrc.lockfile = v; + } + if let Some(v) = self.prefer_frozen_lockfile { + npmrc.prefer_frozen_lockfile = v; + } + if let Some(v) = self.lockfile_include_tarball_url { + npmrc.lockfile_include_tarball_url = v; + } + if let Some(v) = self.registry { + npmrc.registry = if v.ends_with('/') { v } else { format!("{v}/") }; + } + if let Some(v) = self.auto_install_peers { + npmrc.auto_install_peers = v; + } + if let Some(v) = self.dedupe_peer_dependents { + npmrc.dedupe_peer_dependents = v; + } + if let Some(v) = self.strict_peer_dependencies { + npmrc.strict_peer_dependencies = v; + } + if let Some(v) = self.resolve_peers_from_workspace_root { + npmrc.resolve_peers_from_workspace_root = v; + } + } +} + +fn resolve(base: &Path, value: &str) -> PathBuf { + let candidate = PathBuf::from(value); + if candidate.is_absolute() { + candidate + } else { + base.join(candidate) + } +} + +fn find_workspace_manifest(start: &Path) -> Option { + let mut cursor = Some(start); + while let Some(dir) = cursor { + let candidate = dir.join(WORKSPACE_MANIFEST_FILENAME); + if candidate.is_file() { + return Some(candidate); + } + cursor = dir.parent(); + } + None +} + +/// Resolve the workspace root for a given starting directory — i.e. the +/// directory containing the nearest ancestor `pnpm-workspace.yaml`. +/// Returns `start` itself if no manifest is found, so callers can always +/// use the result as a resolution base. +pub fn workspace_root_or(start: &Path) -> PathBuf { + find_workspace_manifest(start) + .and_then(|path| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| start.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_common_settings_from_yaml() { + let yaml = r#" +storeDir: ../my-store +registry: https://reg.example +lockfile: false +autoInstallPeers: true +nodeLinker: hoisted +packages: + - packages/* +"#; + let settings: WorkspaceSettings = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(settings.store_dir.as_deref(), Some("../my-store")); + assert_eq!(settings.registry.as_deref(), Some("https://reg.example")); + assert_eq!(settings.lockfile, Some(false)); + assert_eq!(settings.auto_install_peers, Some(true)); + assert!(matches!(settings.node_linker, Some(NodeLinker::Hoisted))); + } + + #[test] + fn swallows_unknown_top_level_keys() { + let yaml = r#" +catalog: + react: ^18 +onlyBuiltDependencies: + - esbuild +packages: + - "apps/*" +"#; + // `pnpm-workspace.yaml` commonly contains top-level keys we do not + // model in `WorkspaceSettings` (packages list, catalogs, build + // allow-lists, …). This guards against regressions that would make + // serde reject those unknown keys during deserialization — i.e. + // someone adding `deny_unknown_fields` to the struct. + let _settings: WorkspaceSettings = serde_yaml::from_str(yaml).unwrap(); + } + + #[test] + fn apply_overrides_npmrc_defaults() { + let yaml = r#" +storeDir: /absolute/store +lockfile: false +registry: https://reg.example +"#; + let settings: WorkspaceSettings = serde_yaml::from_str(yaml).unwrap(); + let mut npmrc = Npmrc::new(); + npmrc.lockfile = true; + let before_registry = npmrc.registry.clone(); + + settings.apply_to(&mut npmrc, Path::new("/irrelevant-for-absolute-paths")); + + assert_eq!(npmrc.store_dir.display().to_string(), "/absolute/store"); + assert!(!npmrc.lockfile); + assert_eq!(npmrc.registry, "https://reg.example/"); + assert_ne!(before_registry, npmrc.registry); + } + + #[test] + fn apply_resolves_relative_paths_against_base_dir() { + let yaml = "storeDir: ../shared-store\n"; + let settings: WorkspaceSettings = serde_yaml::from_str(yaml).unwrap(); + let mut npmrc = Npmrc::new(); + + settings.apply_to(&mut npmrc, Path::new("/workspace/root")); + + assert_eq!(npmrc.store_dir.display().to_string(), "/workspace/root/../shared-store"); + } + + #[test] + fn apply_leaves_unset_fields_alone() { + let yaml = "storeDir: /s\n"; + let settings: WorkspaceSettings = serde_yaml::from_str(yaml).unwrap(); + let mut npmrc = Npmrc::new(); + let before = + (npmrc.hoist, npmrc.lockfile, npmrc.registry.clone(), npmrc.auto_install_peers); + + settings.apply_to(&mut npmrc, Path::new("/anywhere")); + + assert_eq!( + (npmrc.hoist, npmrc.lockfile, npmrc.registry.clone(), npmrc.auto_install_peers), + before + ); + } + + #[test] + fn find_walks_up_to_parent_dir() { + let tmp = tempfile::tempdir().unwrap(); + let nested = tmp.path().join("a/b/c"); + fs::create_dir_all(&nested).unwrap(); + fs::write(tmp.path().join("pnpm-workspace.yaml"), "storeDir: /s\n").unwrap(); + + let (found, settings) = WorkspaceSettings::find_and_load(&nested).unwrap().unwrap(); + assert_eq!(found, tmp.path().join("pnpm-workspace.yaml")); + assert_eq!(settings.store_dir.as_deref(), Some("/s")); + } + + #[test] + fn find_returns_none_when_no_manifest() { + let tmp = tempfile::tempdir().unwrap(); + assert!(WorkspaceSettings::find_and_load(tmp.path()).unwrap().is_none()); + } +}