feat(config)!: match pnpm 11 config — yaml for settings, .npmrc for auth only (#248)

* 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::<Npmrc>\` 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`.
This commit is contained in:
Zoltan Kochan
2026-04-23 19:29:35 +02:00
committed by GitHub
parent 0b0abf8189
commit 7dd49048cd
6 changed files with 526 additions and 29 deletions

1
pacquet/Cargo.lock generated
View File

@@ -1675,6 +1675,7 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_ini",
"serde_yaml",
"tempfile",
]

View File

@@ -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");

View File

@@ -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 }

View File

@@ -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<Error, CurrentDir, HomeDir, Default>(
current_dir: CurrentDir,
home_dir: HomeDir,
@@ -172,22 +188,30 @@ impl Npmrc {
HomeDir: FnOnce() -> Option<PathBuf>,
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<Npmrc> {
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<String> {
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!(&current_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);
}

View File

@@ -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<String>,
}
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"));
}
}

View File

@@ -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 <https://pnpm.io/settings> 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<bool>,
pub hoist_pattern: Option<Vec<String>>,
pub public_hoist_pattern: Option<Vec<String>>,
pub shamefully_hoist: Option<bool>,
pub store_dir: Option<String>,
pub modules_dir: Option<String>,
pub node_linker: Option<NodeLinker>,
pub symlink: Option<bool>,
pub virtual_store_dir: Option<String>,
pub package_import_method: Option<PackageImportMethod>,
pub modules_cache_max_age: Option<u64>,
pub lockfile: Option<bool>,
pub prefer_frozen_lockfile: Option<bool>,
pub lockfile_include_tarball_url: Option<bool>,
pub registry: Option<String>,
pub auto_install_peers: Option<bool>,
pub dedupe_peer_dependents: Option<bool>,
pub strict_peer_dependencies: Option<bool>,
pub resolve_peers_from_workspace_root: Option<bool>,
}
/// 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<Option<(PathBuf, Self)>, 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<PathBuf> {
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());
}
}