From be8e8ea747697c882be827e1f4a239491d4c0f1a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 7 Jul 2026 19:30:20 +0200 Subject: [PATCH] feat(pacquet): expose linkWorkspacePackages in the napi binding (#12844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `linkWorkspacePackages` (`true` / `false` / `"deep"`) to the `@pnpm/napi` install options, mapping it onto `Config::link_workspace_packages` (reusing the config crate's `Deserialize`). Programmatic consumers need this to opt bare-semver dependencies — including an auto-installed peer that names a sibling workspace package — into workspace-package resolution, matching pnpm's `link-workspace-packages` setting. The default stays `false`, so existing behavior is unchanged. --- pacquet/crates/napi/src/config.rs | 10 +++++++- pacquet/crates/napi/src/install.rs | 26 +++++++++++++++++++++ pacquet/crates/napi/src/install/tests.rs | 29 ++++++++++++++++++++++++ pacquet/npm/napi/index.d.ts | 6 +++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/pacquet/crates/napi/src/config.rs b/pacquet/crates/napi/src/config.rs index 6e1737c8a1..7191f5963d 100644 --- a/pacquet/crates/napi/src/config.rs +++ b/pacquet/crates/napi/src/config.rs @@ -34,7 +34,8 @@ use std::{ use dashmap::DashMap; use indexmap::IndexMap; use pacquet_config::{ - Config, GetHomeDir, Host, LoadWorkspaceYamlError, NodeLinker, PackageImportMethod, + Config, GetHomeDir, Host, LinkWorkspacePackages, LoadWorkspaceYamlError, NodeLinker, + PackageImportMethod, }; use pacquet_network::{AuthHeaders, ProxyConfig, TlsConfig}; use pacquet_store_dir::StoreDir; @@ -52,6 +53,10 @@ pub struct ConfigOverlay { pub proxy: Option, pub tls: Option, pub node_linker: Option, + /// `linkWorkspacePackages` — whether a bare-semver dependency may resolve + /// to a workspace package by name. `Off` (the default) matches only + /// `workspace:`-prefixed ranges. + pub link_workspace_packages: Option, pub package_import_method: Option, pub virtual_store_dir_max_length: Option, pub hoist_pattern: Option>, @@ -262,6 +267,9 @@ fn build_config(dir: &Path, overlay: &ConfigOverlay) -> Result, pub network_config: Option, pub node_linker: Option, + /// `linkWorkspacePackages` — `true` / `false` / `"deep"`. When enabled, a + /// bare-semver dependency may resolve to a workspace package by name (not + /// only `workspace:`-prefixed ranges). + pub link_workspace_packages: Option, pub hoist_pattern: Option>, pub public_hoist_pattern: Option>, pub external_dependencies: Option>, @@ -416,6 +420,9 @@ fn build_overlay(options: &InstallOptions) -> napi::Result { proxy: options.proxy_config.as_ref().map(build_proxy_config).transpose()?, tls: network_config.map(build_tls_config).transpose()?, node_linker: options.node_linker.as_deref().and_then(parse_node_linker), + link_workspace_packages: parse_link_workspace_packages( + options.link_workspace_packages.as_ref(), + )?, package_import_method: options .package_import_method .as_deref() @@ -600,6 +607,25 @@ fn parse_node_linker(value: &str) -> Option { } } +/// Parse the JS `linkWorkspacePackages` value (`true` / `false` / `"deep"`) +/// into a [`pacquet_config::LinkWorkspacePackages`], reusing the config +/// crate's `Deserialize`. Rejects any other value. +fn parse_link_workspace_packages( + value: Option<&serde_json::Value>, +) -> napi::Result> { + value + .map(|value| { + serde_json::from_value::(value.clone()).map_err( + |error| { + napi::Error::from_reason(format!( + r#"invalid linkWorkspacePackages (expected true, false, or "deep"): {error}"#, + )) + }, + ) + }) + .transpose() +} + fn parse_import_method(value: &str) -> Option { match value { "auto" => Some(pacquet_config::PackageImportMethod::Auto), diff --git a/pacquet/crates/napi/src/install/tests.rs b/pacquet/crates/napi/src/install/tests.rs index 8f9c123467..d57154a815 100644 --- a/pacquet/crates/napi/src/install/tests.rs +++ b/pacquet/crates/napi/src/install/tests.rs @@ -86,6 +86,34 @@ fn build_overlay_maps_supported_install_options() { assert_eq!(tls.local_address.map(|ip| ip.to_string()), Some("127.0.0.1".to_string())); } +#[test] +fn build_overlay_parses_link_workspace_packages() { + use pacquet_config::LinkWorkspacePackages; + + let mut options = install_options(); + options.link_workspace_packages = Some(serde_json::json!("deep")); + assert_eq!( + build_overlay(&options).expect("overlay").link_workspace_packages, + Some(LinkWorkspacePackages::Deep), + ); + + options.link_workspace_packages = Some(serde_json::json!(true)); + assert_eq!( + build_overlay(&options).expect("overlay").link_workspace_packages, + Some(LinkWorkspacePackages::DirectOnly), + ); + + options.link_workspace_packages = Some(serde_json::json!(false)); + assert_eq!( + build_overlay(&options).expect("overlay").link_workspace_packages, + Some(LinkWorkspacePackages::Off), + ); + + // Anything other than a boolean or "deep" is rejected. + options.link_workspace_packages = Some(serde_json::json!("shallow")); + assert!(build_overlay(&options).is_err()); +} + #[test] fn unsupported_install_options_fail_closed() { let mut options = install_options(); @@ -143,6 +171,7 @@ fn install_options() -> InstallOptions { proxy_config: None, network_config: None, node_linker: None, + link_workspace_packages: None, hoist_pattern: None, public_hoist_pattern: None, external_dependencies: None, diff --git a/pacquet/npm/napi/index.d.ts b/pacquet/npm/napi/index.d.ts index 8471699e9a..7bad47c217 100644 --- a/pacquet/npm/napi/index.d.ts +++ b/pacquet/npm/napi/index.d.ts @@ -90,6 +90,12 @@ export interface InstallOptions extends SharedEngineOptions { projects: NodeApiProject[] storeDir?: string nodeLinker?: 'hoisted' | 'isolated' + /** + * pnpm's `linkWorkspacePackages`. When `true`/`'deep'`, a bare-semver + * dependency (including an auto-installed peer) may resolve to a workspace + * package by name; `false` (the default) matches only `workspace:` ranges. + */ + linkWorkspacePackages?: boolean | 'deep' hoistPattern?: string[] publicHoistPattern?: string[] /** Packages linked from outside the workspace; excluded from hoisting/pruning. */