diff --git a/Cargo.lock b/Cargo.lock index 7e64a89932..ba382bfdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,6 +2179,7 @@ dependencies = [ "pacquet-patching", "pacquet-store-dir", "pacquet-testing-utils", + "pacquet-workspace-state", "pipe-trait", "pretty_assertions", "serde", diff --git a/pacquet/crates/config/Cargo.toml b/pacquet/crates/config/Cargo.toml index af3b715c4b..73a3bbb1fd 100644 --- a/pacquet/crates/config/Cargo.toml +++ b/pacquet/crates/config/Cargo.toml @@ -18,6 +18,7 @@ pacquet-network = { workspace = true } pacquet-package-is-installable = { workspace = true } pacquet-patching = { workspace = true } pacquet-store-dir = { workspace = true } +pacquet-workspace-state = { workspace = true } derive_more = { workspace = true } home = { workspace = true } diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index 6f6059b5e3..4fa36bc0b7 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -12,6 +12,7 @@ pub use crate::api::{EnvVar, EnvVarOs, GetCurrentDir, GetHomeDir, Host, LinkProb use indexmap::IndexMap; use pacquet_patching::{PatchGroupRecord, ResolvePatchedDependenciesError, resolve_and_group}; use pacquet_store_dir::StoreDir; +use pacquet_workspace_state::ConfigDependency; use pipe_trait::Pipe; use serde::Deserialize; use smart_default::SmartDefault; @@ -895,6 +896,15 @@ pub struct Config { /// [`addSettingsFromWorkspaceManifestToConfig`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/index.ts#L803-L831). pub patched_dependencies: Option>, + /// Raw `configDependencies` from `pnpm-workspace.yaml`: package + /// name → version-with-integrity spec. Recorded verbatim in the + /// workspace-state file so pnpm's `checkDepsStatus` sees the same + /// value it holds in the live config and doesn't treat the install + /// as stale. See [`WorkspaceSettings::config_dependencies`]. + /// + /// [`WorkspaceSettings::config_dependencies`]: crate::workspace_yaml::WorkspaceSettings::config_dependencies + pub config_dependencies: Option>, + /// `pnpm.allowBuilds` from `pnpm-workspace.yaml`: package names /// (or `name@version` keys) that are allowed to run lifecycle /// scripts. pnpm 11 denies scripts by default; the allow-list is diff --git a/pacquet/crates/config/src/workspace_yaml.rs b/pacquet/crates/config/src/workspace_yaml.rs index 67e2b0644a..57d83b25cc 100644 --- a/pacquet/crates/config/src/workspace_yaml.rs +++ b/pacquet/crates/config/src/workspace_yaml.rs @@ -8,6 +8,7 @@ use miette::Diagnostic; use pacquet_env_replace::env_replace_lossy; use pacquet_package_is_installable::SupportedArchitectures; use pacquet_store_dir::StoreDir; +use pacquet_workspace_state::ConfigDependency; use pipe_trait::Pipe; use serde::{Deserialize, Deserializer}; use std::{ @@ -205,6 +206,17 @@ pub struct WorkspaceSettings { /// [`BTreeMap`]: std::collections::BTreeMap pub patched_dependencies: Option>, + /// `configDependencies` from `pnpm-workspace.yaml`: package name → + /// version-with-integrity spec. pnpm records this verbatim in the + /// workspace-state file so that `checkDepsStatus` can detect when a + /// config dependency changed and force a reinstall. Pacquet must + /// write the same value back (see + /// [`build_workspace_state`](../../package-manager/src/install.rs)), + /// otherwise pnpm reads a missing `configDependencies` on the next + /// `pnpm run` / `pnpm node`, compares it against the live config, + /// and reinstalls on every invocation. + pub config_dependencies: Option>, + /// Map of `name[@version]` → `true` / `false`. Drives pnpm 11's /// default-deny build policy: a package's lifecycle scripts only /// run when an entry here resolves to `true`. Mirrors upstream's @@ -550,6 +562,7 @@ impl WorkspaceSettings { self.hoisting_limits = None; self.external_dependencies = None; self.patched_dependencies = None; + self.config_dependencies = None; self.allow_builds = None; self.supported_architectures = None; self.ignored_optional_dependencies = None; @@ -716,6 +729,9 @@ impl WorkspaceSettings { if let Some(v) = self.patched_dependencies { config.patched_dependencies = Some(v); } + if let Some(v) = self.config_dependencies { + config.config_dependencies = Some(v); + } if let Some(v) = self.allow_builds { config.allow_builds = v; } diff --git a/pacquet/crates/config/src/workspace_yaml/tests.rs b/pacquet/crates/config/src/workspace_yaml/tests.rs index bd272867de..7c20c63f92 100644 --- a/pacquet/crates/config/src/workspace_yaml/tests.rs +++ b/pacquet/crates/config/src/workspace_yaml/tests.rs @@ -4,6 +4,7 @@ use crate::{ ScriptsPrependNodePath, TrustPolicy, api::EnvVar, }; use pacquet_store_dir::StoreDir; +use pacquet_workspace_state::{ConfigDependency, ConfigDependencyDetail}; use pipe_trait::Pipe; use pretty_assertions::assert_eq; use std::{fs, path::Path}; @@ -326,6 +327,65 @@ patchedDependencies: assert_eq!(map.get("lodash@4.17.21").map(String::as_str), Some("patches/lodash@4.17.21.patch")); } +/// `configDependencies` is a map of package name → version-with-integrity +/// spec. pacquet records it into the workspace-state file so pnpm's +/// `checkDepsStatus` doesn't treat the install as stale on the next +/// `pnpm run` / `pnpm node`. Guards the camelCase rename, optionality, +/// and `apply_to` wiring. +#[test] +fn parses_config_dependencies_from_yaml_and_applies() { + let yaml = r#" +configDependencies: + "@pnpm/pacquet": 0.2.2-14 +"#; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let expected = settings.config_dependencies.clone(); + assert_eq!( + expected.as_ref().and_then(|m| m.get("@pnpm/pacquet")), + Some(&ConfigDependency::VersionWithIntegrity("0.2.2-14".to_string())), + ); + + let mut config = Config::new(); + assert!(config.config_dependencies.is_none(), "default is None"); + settings.apply_to(&mut config, Path::new("/irrelevant")); + assert_eq!(config.config_dependencies, expected); +} + +/// pnpm's `configDependencies` value can also be the `{ tarball?, integrity }` +/// object form. It must parse (not error) and round-trip, otherwise an +/// upstream-supported manifest becomes a hard config-load failure. +#[test] +fn parses_object_form_config_dependencies() { + let yaml = r#" +configDependencies: + "@scope/dep": + integrity: sha512-abc + tarball: https://example.test/dep.tgz +"#; + let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + let map = settings.config_dependencies.expect("field present"); + assert_eq!( + map.get("@scope/dep"), + Some(&ConfigDependency::Detailed(ConfigDependencyDetail { + integrity: "sha512-abc".to_string(), + tarball: Some("https://example.test/dep.tgz".to_string()), + })), + ); +} + +/// `configDependencies` is workspace-only: it must not be honored from +/// the global `config.yaml`, matching pnpm's `isConfigFileKey` filter. +#[test] +fn config_dependencies_cleared_as_workspace_only_field() { + let yaml = r#" +configDependencies: + "@pnpm/pacquet": 0.2.2-14 +"#; + let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + settings.clear_workspace_only_fields(); + assert!(settings.config_dependencies.is_none()); +} + /// `allowBuilds` is a map of `name[@version]` → bool. Same camelCase /// rename + `apply_to` wiring as the other yaml-sourced settings. /// pnpm 10+ moved this out of `package.json#pnpm` (matches diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 99e5688cb5..05d93f8ff5 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -1597,7 +1597,7 @@ fn build_workspace_state( // `false` so pnpm doesn't treat the install as partial and // skip the cache. filtered_install: false, - config_dependencies: None, + config_dependencies: config.config_dependencies.clone(), // Settings construction is shared with // `optimistic_repeat_install::current_settings` so the // freshness check sees the same byte shape this writer diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index 9be9de3198..f7a18034b1 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -900,6 +900,8 @@ mod build_workspace_state_tests { use pacquet_config::Config; use pacquet_modules_yaml::IncludedDependencies; use pacquet_package_manifest::PackageManifest; + use pacquet_workspace_state::ConfigDependency; + use std::collections::BTreeMap; use std::path::PathBuf; use tempfile::tempdir; @@ -965,6 +967,28 @@ mod build_workspace_state_tests { assert!(packages.contains(&entry.name.as_deref().unwrap_or_default(),)); } } + + /// pnpm's `createWorkspaceState` records `configDependencies` + /// verbatim. When pacquet is the install engine for a project that + /// declares one (the `@pnpm/pacquet` configDependency itself), the + /// written state must carry the same map — otherwise pnpm's + /// `checkDepsStatus` reads a missing value, treats the install as + /// stale, and reinstalls on every `pnpm run` / `pnpm node`. + #[test] + fn records_config_dependencies_from_config() { + let mut config = Config::new(); + config.config_dependencies = Some(BTreeMap::from([( + "@pnpm/pacquet".to_string(), + ConfigDependency::VersionWithIntegrity("0.2.2-14".to_string()), + )])); + let state = build_workspace_state( + &config, + pacquet_config::NodeLinker::default(), + IncludedDependencies::default(), + &[], + ); + assert_eq!(state.config_dependencies, config.config_dependencies); + } } /// Ports `'do not fail on an optional dependency that has a non-optional diff --git a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs index 7351b7f36e..e737800bc0 100644 --- a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs +++ b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs @@ -74,6 +74,7 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path) npmrc_auth_file: None, workspace_dir: None, patched_dependencies: None, + config_dependencies: None, allow_builds: Default::default(), dangerously_allow_all_builds: false, scripts_prepend_node_path: Default::default(), diff --git a/pacquet/crates/workspace-state/src/lib.rs b/pacquet/crates/workspace-state/src/lib.rs index 5cb77a1e69..b73d62328d 100644 --- a/pacquet/crates/workspace-state/src/lib.rs +++ b/pacquet/crates/workspace-state/src/lib.rs @@ -45,6 +45,27 @@ pub struct ProjectEntry { pub version: Option, } +/// A single `configDependencies` value. Mirrors pnpm's +/// `VersionWithIntegrity | { tarball?, integrity }` at +/// . +/// Untagged so it round-trips both shapes verbatim; pnpm compares the +/// recorded value against the live config with a deep, order-independent +/// equality check. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ConfigDependency { + VersionWithIntegrity(String), + Detailed(ConfigDependencyDetail), +} + +/// The `{ tarball?, integrity }` form of a [`ConfigDependency`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfigDependencyDetail { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tarball: Option, + pub integrity: String, +} + /// Typed view of `.pnpm-workspace-state-v1.json`. /// /// Mirrors upstream's [`WorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts). @@ -59,7 +80,7 @@ pub struct WorkspaceState { pub pnpmfiles: Vec, pub filtered_install: bool, #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_dependencies: Option>, + pub config_dependencies: Option>, pub settings: WorkspaceStateSettings, }