mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-23 14:13:09 -04:00
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:
1
pacquet/Cargo.lock
generated
1
pacquet/Cargo.lock
generated
@@ -1675,6 +1675,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_ini",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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!(¤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);
|
||||
}
|
||||
|
||||
|
||||
127
pacquet/crates/npmrc/src/npmrc_auth.rs
Normal file
127
pacquet/crates/npmrc/src/npmrc_auth.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
285
pacquet/crates/npmrc/src/workspace_yaml.rs
Normal file
285
pacquet/crates/npmrc/src/workspace_yaml.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user