From 286af393bcf6ae249ba7959f6e2d5c9620794830 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:12:42 -0400 Subject: [PATCH 1/2] Route pnpm installs to pnpm upgrade commands (WIP) Co-Authored-By: Claude Opus 5.5 (1M context) From f5b7f69d649257f15255c3b525dc0d02847d3964 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:46:47 -0400 Subject: [PATCH 2/2] Give pnpm installs pnpm upgrade commands socket-patch --update and the update notice treated every node_modules install as npm. A pnpm global install was told to run npm update -g, which never sees pnpm's store, and a pnpm project was told to run npm install, which writes a stray package-lock.json and breaks the next pnpm install --frozen-lockfile. pnpm installs are now recognized by the .pnpm virtual store in the binary's path, a pnpm home above a global store, or pnpm-lock.yaml beside node_modules. They are labelled "managed by pnpm" and given pnpm add -g ...@latest (global), pnpm add ...@latest (project) or pnpm update --recursive --latest ... (workspace root). vlt installs are now labelled vlt. Fixes #1111 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-cli/src/commands/update.rs | 6 +- .../tests/update/self_update_channels_e2e.rs | 51 ++++ .../socket-patch-core/src/update/channel.rs | 220 ++++++++++++++++-- crates/socket-patch-core/src/update/mod.rs | 3 +- 5 files changed, 259 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..99c0a6ebc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1012,7 +1012,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf | Detected channel | Hint | |---|---| -| npm (`node_modules` path component) | project-local (the directory holding the outermost `node_modules` has a `package.json`, and it is not directly under `lib`/`npm` or below a yarn/pnpm `global` store): `npm install @socketsecurity/socket-patch@latest`, or `vlt install @socketsecurity/socket-patch@latest` when that directory holds `vlt-lock.json`, or `vlx -y -- @socketsecurity/socket-patch@latest …` when its `package.json` is vlx's (`"name": "vlx"`, the vlx cache); otherwise global (including version-manager prefixes such as nvm-windows and fnm): `npm update -g @socketsecurity/socket-patch` | +| npm family (`node_modules` path component) | The refusal names the owning manager (`managed by npm` / `pnpm` / `vlt`). **pnpm** (a `.pnpm` virtual-store component in the path, a `pnpm` home above a `global` store, or `pnpm-lock.yaml` beside the outermost `node_modules`): global store: `pnpm add -g @socketsecurity/socket-patch@latest`; project: `pnpm add @socketsecurity/socket-patch@latest`, or `pnpm update --recursive --latest @socketsecurity/socket-patch` when that directory holds `pnpm-workspace.yaml`. **npm / vlt** project-local (the directory holding the outermost `node_modules` has a `package.json`, and it is not directly under `lib`/`npm` or below a yarn/pnpm `global` store): `npm install @socketsecurity/socket-patch@latest`, or `vlt install @socketsecurity/socket-patch@latest` when that directory holds `vlt-lock.json`, or `vlx -y -- @socketsecurity/socket-patch@latest …` when its `package.json` is vlx's (`"name": "vlx"`, the vlx cache); otherwise global (including a yarn `global` store and version-manager prefixes such as nvm-windows and fnm): `npm update -g @socketsecurity/socket-patch` | | Legacy PyPI wheel (`site-packages`/`dist-packages`) | `pip uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | | `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` | | Legacy gem launcher cache (`/socket-patch/bin/…`) | `gem uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index 8a9009abb..52ac94797 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -9,7 +9,7 @@ use clap::Args; use socket_patch_core::update::{ - self as core_update, asset_name_for_target, channel_label, current_version, detect_channel, + self as core_update, asset_name_for_target, channel_label_for, current_version, detect_channel, fetch_latest_version, is_newer, upgrade_hint_for, ChannelEnv, InstallChannel, UpdateEndpoints, UpdateError, UpdateRequest, UpdateTimeouts, }; @@ -212,7 +212,7 @@ pub async fn run(args: UpdateArgs) -> i32 { format!( "this install is managed by {} — its next upgrade will overwrite \ the updated binary.", - channel_label(channel) + channel_label_for(channel, &install_path) ), ); } else { @@ -223,7 +223,7 @@ pub async fn run(args: UpdateArgs) -> i32 { "this socket-patch binary ({}) is managed by {}; update it with `{}` \ instead, or pass --force to replace it in place", install_path.display(), - channel_label(channel), + channel_label_for(channel, &install_path), hint ), ); diff --git a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs index 6ec85f7ef..e65a0356b 100644 --- a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs @@ -59,6 +59,57 @@ async fn npm_project_local_refuses_with_local_hint() { ); } +/// #1111: a pnpm project install refuses with pnpm's own command. Running +/// `npm install …` there writes a stray package-lock.json and leaves +/// pnpm-lock.yaml stale, so the next `pnpm install --frozen-lockfile` +/// fails. +#[tokio::test] +async fn pnpm_project_refuses_with_pnpm_hint() { + let install = staged_install_at( + "app/node_modules/.pnpm/@socketsecurity+socket-patch-x@4.0.0/node_modules/@socketsecurity/socket-patch-x/bin", + ); + std::fs::write(install.root.path().join("app/package.json"), "{}").unwrap(); + std::fs::write(install.root.path().join("app/pnpm-lock.yaml"), "").unwrap(); + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)], + ); + assert_eq!(code, 1, "managed install must refuse.\nstderr:\n{stderr}"); + assert!(stderr.contains("managed by pnpm"), "{stderr}"); + assert!( + stderr.contains("`pnpm add @socketsecurity/socket-patch@latest`"), + "a pnpm project must get pnpm's upgrade command: {stderr}" + ); + assert!(!stderr.contains("npm install"), "{stderr}"); + install.assert_binary_intact(); +} + +/// #1111: pnpm's global store is invisible to `npm update -g`, so a pnpm +/// global install must be told to use `pnpm add -g`. +#[tokio::test] +async fn pnpm_global_refuses_with_pnpm_hint() { + let install = staged_install_at( + "pnpm/global/5/node_modules/.pnpm/@socketsecurity+socket-patch-x@4.0.0/node_modules/@socketsecurity/socket-patch-x/bin", + ); + let (code, stdout, _stderr) = run_installed( + &install, + &["--update", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)], + ); + assert_eq!(code, 1, "managed install must refuse.\nstdout:\n{stdout}"); + let env = common::parse_json_envelope(&stdout); + let message = env["error"]["message"].as_str().unwrap_or_default(); + assert_eq!(env["error"]["code"], "managed_install", "{stdout}"); + assert!(message.contains("managed by pnpm"), "{stdout}"); + assert!( + message.contains("`pnpm add -g @socketsecurity/socket-patch@latest`"), + "{stdout}" + ); + assert!(!message.contains("npm update -g"), "{stdout}"); + install.assert_binary_intact(); +} + /// An npm-bundled binary (any `node_modules` component) refuses with the /// npm upgrade command — and the refusal happens before ANY release /// traffic: a fully valid, newer release is mounted and its routes must diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 6b6d7838b..263a8c975 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -121,28 +121,117 @@ pub fn upgrade_hint(channel: InstallChannel) -> &'static str { } } -/// [`upgrade_hint`] for the binary at `canonical_exe`. An npm install is -/// either global (`/lib/node_modules`, `%APPDATA%\npm\node_modules`, -/// a yarn/pnpm `global` store, a Windows version-manager dir such as -/// nvm-windows' `%APPDATA%\nvm\v20.11.0\node_modules`), where -/// `npm update -g` is right, or a project dependency -/// (`/node_modules`), where `-g` would update some other copy and -/// leave this one alone. A project that vlt installed (its root holds -/// `vlt-lock.json`) upgrades through vlt, and vlx's cache dir (a project -/// whose `package.json` is named `vlx`, under `$XDG_DATA_HOME/vlt/vlx/`) -/// is refreshed by running vlx with `@latest`. +/// [`upgrade_hint`] for the binary at `canonical_exe`. A `node_modules` +/// install is upgraded through the package manager that owns it +/// ([`node_manager`]): +/// +/// - npm: a global install (`/lib/node_modules`, +/// `%APPDATA%\npm\node_modules`, a yarn `global` store, a Windows +/// version-manager dir such as nvm-windows' +/// `%APPDATA%\nvm\v20.11.0\node_modules`) takes `npm update -g`; a +/// project dependency (`/node_modules`) takes `npm install …`, +/// since `-g` would update some other copy and leave this one alone. +/// - pnpm: `pnpm add -g …@latest` for its global store, `pnpm add …@latest` +/// in a project, and `pnpm update --recursive --latest …` at a workspace +/// root, which refuses a bare `pnpm add` and whose members may declare the +/// dependency. npm's commands are wrong here: `npm update -g` never sees +/// pnpm's global store, and `npm install` in a pnpm project writes a +/// stray `package-lock.json` and leaves `pnpm-lock.yaml` stale. +/// - vlt: a project that vlt installed (its root holds `vlt-lock.json`) +/// upgrades through vlt, and vlx's cache dir (a project whose +/// `package.json` is named `vlx`, under `$XDG_DATA_HOME/vlt/vlx/`) is +/// refreshed by running vlx with `@latest`. pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { - if channel == InstallChannel::Npm && !is_global_npm_install(canonical_exe) { - let holder = outermost_node_modules_holder(canonical_exe); + if channel != InstallChannel::Npm { + return upgrade_hint(channel); + } + let global = is_global_npm_install(canonical_exe); + let holder = outermost_node_modules_holder(canonical_exe); + match node_manager(canonical_exe) { + NodeManager::Pnpm if global => "pnpm add -g @socketsecurity/socket-patch@latest", + NodeManager::Pnpm + if holder.is_some_and(|dir| { + dir.join(crate::utils::pnpm_workspace::PNPM_WORKSPACE) + .is_file() + }) => + { + "pnpm update --recursive --latest @socketsecurity/socket-patch" + } + NodeManager::Pnpm => "pnpm add @socketsecurity/socket-patch@latest", + _ if global => upgrade_hint(channel), + NodeManager::Vlx => "vlx -y -- @socketsecurity/socket-patch@latest …", + NodeManager::Vlt => "vlt install @socketsecurity/socket-patch@latest", + NodeManager::Npm => "npm install @socketsecurity/socket-patch@latest", + } +} + +/// Short human label for refusal messages ("managed by pnpm"): the +/// [`channel_label`], naming the npm-family manager for a `node_modules` +/// install. +pub fn channel_label_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { + if channel != InstallChannel::Npm { + return channel_label(channel); + } + match node_manager(canonical_exe) { + NodeManager::Npm => "npm", + NodeManager::Pnpm => "pnpm", + NodeManager::Vlt | NodeManager::Vlx => "vlt", + } +} + +/// The npm-family manager that owns a `node_modules` install. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NodeManager { + Npm, + Pnpm, + Vlt, + Vlx, +} + +/// Classify the owner of the `node_modules` install at `path`. vlt is only +/// recognized for project installs (its lockfile beside the outermost +/// `node_modules`, or vlx's cache project); pnpm by its `.pnpm` virtual +/// store in the canonical path (every isolated-linker install, global or +/// project), a `pnpm` home above a `global` store, or a `pnpm-lock.yaml` +/// beside the outermost `node_modules` (`node-linker=hoisted`). Anything +/// else is npm, which also covers yarn and Bun (no dedicated hint yet). +fn node_manager(path: &Path) -> NodeManager { + let holder = outermost_node_modules_holder(path); + if !is_global_npm_install(path) { if holder.is_some_and(is_vlx_cache_dir) { - return "vlx -y -- @socketsecurity/socket-patch@latest …"; + return NodeManager::Vlx; } if holder.is_some_and(|dir| dir.join(crate::constants::npm_family::VLT_LOCK).is_file()) { - return "vlt install @socketsecurity/socket-patch@latest"; + return NodeManager::Vlt; } - return "npm install @socketsecurity/socket-patch@latest"; } - upgrade_hint(channel) + if has_component(path, ".pnpm") + || is_pnpm_global_store(path) + || holder.is_some_and(|dir| dir.join(crate::constants::npm_family::PNPM_LOCK).is_file()) + { + return NodeManager::Pnpm; + } + NodeManager::Npm +} + +/// A `global` store below a `pnpm` home (`~/Library/pnpm/global/5`, +/// `~/.local/share/pnpm/global/v11/`, `%LOCALAPPDATA%\pnpm\global\5`). +fn is_pnpm_global_store(path: &Path) -> bool { + let names: Vec<&std::ffi::OsStr> = path + .components() + .filter_map(|c| match c { + Component::Normal(os) => Some(os), + _ => None, + }) + .collect(); + let first_nm = names + .iter() + .position(|n| *n == "node_modules") + .unwrap_or(names.len()); + names[..first_nm] + .iter() + .position(|n| *n == "global") + .is_some_and(|g| names[..g].iter().any(|n| *n == "pnpm")) } /// vlx installs each package into its own project dir whose generated @@ -199,7 +288,8 @@ fn is_global_npm_install(path: &Path) -> bool { } } -/// Short human label for refusal messages ("managed by npm"). +/// Short human label for refusal messages ("managed by npm"); see +/// [`channel_label_for`] for the npm-family manager of a given path. pub fn channel_label(channel: InstallChannel) -> &'static str { match channel { InstallChannel::Standalone => "standalone", @@ -526,7 +616,6 @@ mod tests { "/usr/local/lib/node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", "/home/u/.nvm/versions/node/v20.1.0/lib/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", "/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", - "/Users/u/Library/pnpm/global/5/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", ]; for p in global { assert_eq!( @@ -647,6 +736,101 @@ mod tests { ); } + /// #1111: a pnpm install upgrades through pnpm. `npm update -g` is a + /// no-op for a pnpm global store, and `npm install` in a pnpm project + /// writes a stray package-lock.json and leaves pnpm-lock.yaml stale. + #[test] + fn pnpm_global_installs_get_pnpm_add_g() { + let global = [ + // pnpm 9/10, macOS default PNPM_HOME. + "/Users/u/Library/pnpm/global/5/node_modules/.pnpm/@socketsecurity+socket-patch-darwin-arm64@4.0.0/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", + // The store root itself, as the old table row listed it. + "/Users/u/Library/pnpm/global/5/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", + // pnpm 12 (`global/v11/`), Linux default PNPM_HOME. + "/home/u/.local/share/pnpm/global/v11/ab12cd/node_modules/.pnpm/@socketsecurity+socket-patch-linux-x64@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + // A custom PNPM_HOME without a `pnpm` component: the virtual + // store (`.pnpm`) still names the manager. + "/opt/tools/global/5/node_modules/.pnpm/@socketsecurity+socket-patch-linux-x64@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + ]; + for p in global { + let p = Path::new(p); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, p), + "pnpm add -g @socketsecurity/socket-patch@latest", + "{}", + p.display() + ); + assert_eq!(channel_label_for(InstallChannel::Npm, p), "pnpm"); + } + // Yarn's global store is not pnpm's. + let yarn = Path::new( + "/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + ); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, yarn), + "npm update -g @socketsecurity/socket-patch" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, yarn), "npm"); + } + + #[test] + fn pnpm_project_installs_get_pnpm_add() { + let isolated = "node_modules/.pnpm/@socketsecurity+socket-patch-linux-x64@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch"; + let hoisted = "node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch"; + + // The default isolated linker: the `.pnpm` virtual store decides, + // even before the first `pnpm install` wrote a lockfile. + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("package.json"), "{}").unwrap(); + let p = project.path().join(isolated); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "pnpm add @socketsecurity/socket-patch@latest" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm"); + // `node-linker=hoisted` has no `.pnpm`: pnpm-lock.yaml decides. + let p = project.path().join(hoisted); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "npm install @socketsecurity/socket-patch@latest" + ); + std::fs::write(project.path().join("pnpm-lock.yaml"), "").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "pnpm add @socketsecurity/socket-patch@latest" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm"); + + // A workspace root refuses a bare `pnpm add` (ERR_PNPM_ADDING_TO_ROOT) + // and the dependency may be declared by any member, so update it + // wherever it is declared. + let ws = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("package.json"), "{}").unwrap(); + std::fs::write(ws.path().join("pnpm-workspace.yaml"), "packages: [a]\n").unwrap(); + let p = ws.path().join(isolated); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "pnpm update --recursive --latest @socketsecurity/socket-patch" + ); + } + + #[test] + fn labels_name_the_owning_node_manager() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("package.json"), "{}").unwrap(); + let bin = project + .path() + .join("node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch"); + assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "npm"); + std::fs::write(project.path().join("vlt-lock.json"), "{}").unwrap(); + assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "vlt"); + // Non-npm channels keep their path-independent label. + assert_eq!( + channel_label_for(InstallChannel::Homebrew, &bin), + channel_label(InstallChannel::Homebrew) + ); + } + #[cfg(windows)] #[test] fn npm_hint_windows_global_prefix() { diff --git a/crates/socket-patch-core/src/update/mod.rs b/crates/socket-patch-core/src/update/mod.rs index 3c1ce623a..1be26ec5b 100644 --- a/crates/socket-patch-core/src/update/mod.rs +++ b/crates/socket-patch-core/src/update/mod.rs @@ -19,7 +19,8 @@ pub mod swap; use std::path::{Path, PathBuf}; pub use channel::{ - channel_label, detect_channel, upgrade_hint, upgrade_hint_for, ChannelEnv, InstallChannel, + channel_label, channel_label_for, detect_channel, upgrade_hint, upgrade_hint_for, ChannelEnv, + InstallChannel, }; pub use release::{ asset_name_for_target, current_version, fetch_latest_version, is_newer, parse_release_tag,