Skip to content

Commit 8ad90d4

Browse files
Give pnpm installs pnpm upgrade commands (#1111) (#1350)
* Route pnpm installs to pnpm upgrade commands (WIP) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4b8e31d commit 8ad90d4

5 files changed

Lines changed: 259 additions & 23 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf
10131013

10141014
| Detected channel | Hint |
10151015
|---|---|
1016-
| 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` |
1016+
| 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` |
10171017
| 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) |
10181018
| `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` |
10191019
| Legacy gem launcher cache (`<cache>/socket-patch/bin/…`) | `gem uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) |

‎crates/socket-patch-cli/src/commands/update.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use clap::Args;
1111
use socket_patch_core::update::{
12-
self as core_update, asset_name_for_target, channel_label, current_version, detect_channel,
12+
self as core_update, asset_name_for_target, channel_label_for, current_version, detect_channel,
1313
fetch_latest_version, is_newer, upgrade_hint_for, ChannelEnv, InstallChannel, UpdateEndpoints,
1414
UpdateError, UpdateRequest, UpdateTimeouts,
1515
};
@@ -212,7 +212,7 @@ pub async fn run(args: UpdateArgs) -> i32 {
212212
format!(
213213
"this install is managed by {} — its next upgrade will overwrite \
214214
the updated binary.",
215-
channel_label(channel)
215+
channel_label_for(channel, &install_path)
216216
),
217217
);
218218
} else {
@@ -223,7 +223,7 @@ pub async fn run(args: UpdateArgs) -> i32 {
223223
"this socket-patch binary ({}) is managed by {}; update it with `{}` \
224224
instead, or pass --force to replace it in place",
225225
install_path.display(),
226-
channel_label(channel),
226+
channel_label_for(channel, &install_path),
227227
hint
228228
),
229229
);

‎crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,57 @@ async fn npm_project_local_refuses_with_local_hint() {
5959
);
6060
}
6161

62+
/// #1111: a pnpm project install refuses with pnpm's own command. Running
63+
/// `npm install …` there writes a stray package-lock.json and leaves
64+
/// pnpm-lock.yaml stale, so the next `pnpm install --frozen-lockfile`
65+
/// fails.
66+
#[tokio::test]
67+
async fn pnpm_project_refuses_with_pnpm_hint() {
68+
let install = staged_install_at(
69+
"app/node_modules/.pnpm/@socketsecurity+socket-patch-x@4.0.0/node_modules/@socketsecurity/socket-patch-x/bin",
70+
);
71+
std::fs::write(install.root.path().join("app/package.json"), "{}").unwrap();
72+
std::fs::write(install.root.path().join("app/pnpm-lock.yaml"), "").unwrap();
73+
let (code, _stdout, stderr) = run_installed(
74+
&install,
75+
&["--update", "--yes"],
76+
&[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)],
77+
);
78+
assert_eq!(code, 1, "managed install must refuse.\nstderr:\n{stderr}");
79+
assert!(stderr.contains("managed by pnpm"), "{stderr}");
80+
assert!(
81+
stderr.contains("`pnpm add @socketsecurity/socket-patch@latest`"),
82+
"a pnpm project must get pnpm's upgrade command: {stderr}"
83+
);
84+
assert!(!stderr.contains("npm install"), "{stderr}");
85+
install.assert_binary_intact();
86+
}
87+
88+
/// #1111: pnpm's global store is invisible to `npm update -g`, so a pnpm
89+
/// global install must be told to use `pnpm add -g`.
90+
#[tokio::test]
91+
async fn pnpm_global_refuses_with_pnpm_hint() {
92+
let install = staged_install_at(
93+
"pnpm/global/5/node_modules/.pnpm/@socketsecurity+socket-patch-x@4.0.0/node_modules/@socketsecurity/socket-patch-x/bin",
94+
);
95+
let (code, stdout, _stderr) = run_installed(
96+
&install,
97+
&["--update", "--yes", "--json"],
98+
&[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)],
99+
);
100+
assert_eq!(code, 1, "managed install must refuse.\nstdout:\n{stdout}");
101+
let env = common::parse_json_envelope(&stdout);
102+
let message = env["error"]["message"].as_str().unwrap_or_default();
103+
assert_eq!(env["error"]["code"], "managed_install", "{stdout}");
104+
assert!(message.contains("managed by pnpm"), "{stdout}");
105+
assert!(
106+
message.contains("`pnpm add -g @socketsecurity/socket-patch@latest`"),
107+
"{stdout}"
108+
);
109+
assert!(!message.contains("npm update -g"), "{stdout}");
110+
install.assert_binary_intact();
111+
}
112+
62113
/// An npm-bundled binary (any `node_modules` component) refuses with the
63114
/// npm upgrade command — and the refusal happens before ANY release
64115
/// traffic: a fully valid, newer release is mounted and its routes must

‎crates/socket-patch-core/src/update/channel.rs‎

Lines changed: 202 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -121,28 +121,117 @@ pub fn upgrade_hint(channel: InstallChannel) -> &'static str {
121121
}
122122
}
123123

124-
/// [`upgrade_hint`] for the binary at `canonical_exe`. An npm install is
125-
/// either global (`<prefix>/lib/node_modules`, `%APPDATA%\npm\node_modules`,
126-
/// a yarn/pnpm `global` store, a Windows version-manager dir such as
127-
/// nvm-windows' `%APPDATA%\nvm\v20.11.0\node_modules`), where
128-
/// `npm update -g` is right, or a project dependency
129-
/// (`<project>/node_modules`), where `-g` would update some other copy and
130-
/// leave this one alone. A project that vlt installed (its root holds
131-
/// `vlt-lock.json`) upgrades through vlt, and vlx's cache dir (a project
132-
/// whose `package.json` is named `vlx`, under `$XDG_DATA_HOME/vlt/vlx/`)
133-
/// is refreshed by running vlx with `@latest`.
124+
/// [`upgrade_hint`] for the binary at `canonical_exe`. A `node_modules`
125+
/// install is upgraded through the package manager that owns it
126+
/// ([`node_manager`]):
127+
///
128+
/// - npm: a global install (`<prefix>/lib/node_modules`,
129+
/// `%APPDATA%\npm\node_modules`, a yarn `global` store, a Windows
130+
/// version-manager dir such as nvm-windows'
131+
/// `%APPDATA%\nvm\v20.11.0\node_modules`) takes `npm update -g`; a
132+
/// project dependency (`<project>/node_modules`) takes `npm install …`,
133+
/// since `-g` would update some other copy and leave this one alone.
134+
/// - pnpm: `pnpm add -g …@latest` for its global store, `pnpm add …@latest`
135+
/// in a project, and `pnpm update --recursive --latest …` at a workspace
136+
/// root, which refuses a bare `pnpm add` and whose members may declare the
137+
/// dependency. npm's commands are wrong here: `npm update -g` never sees
138+
/// pnpm's global store, and `npm install` in a pnpm project writes a
139+
/// stray `package-lock.json` and leaves `pnpm-lock.yaml` stale.
140+
/// - vlt: a project that vlt installed (its root holds `vlt-lock.json`)
141+
/// upgrades through vlt, and vlx's cache dir (a project whose
142+
/// `package.json` is named `vlx`, under `$XDG_DATA_HOME/vlt/vlx/`) is
143+
/// refreshed by running vlx with `@latest`.
134144
pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str {
135-
if channel == InstallChannel::Npm && !is_global_npm_install(canonical_exe) {
136-
let holder = outermost_node_modules_holder(canonical_exe);
145+
if channel != InstallChannel::Npm {
146+
return upgrade_hint(channel);
147+
}
148+
let global = is_global_npm_install(canonical_exe);
149+
let holder = outermost_node_modules_holder(canonical_exe);
150+
match node_manager(canonical_exe) {
151+
NodeManager::Pnpm if global => "pnpm add -g @socketsecurity/socket-patch@latest",
152+
NodeManager::Pnpm
153+
if holder.is_some_and(|dir| {
154+
dir.join(crate::utils::pnpm_workspace::PNPM_WORKSPACE)
155+
.is_file()
156+
}) =>
157+
{
158+
"pnpm update --recursive --latest @socketsecurity/socket-patch"
159+
}
160+
NodeManager::Pnpm => "pnpm add @socketsecurity/socket-patch@latest",
161+
_ if global => upgrade_hint(channel),
162+
NodeManager::Vlx => "vlx -y -- @socketsecurity/socket-patch@latest …",
163+
NodeManager::Vlt => "vlt install @socketsecurity/socket-patch@latest",
164+
NodeManager::Npm => "npm install @socketsecurity/socket-patch@latest",
165+
}
166+
}
167+
168+
/// Short human label for refusal messages ("managed by pnpm"): the
169+
/// [`channel_label`], naming the npm-family manager for a `node_modules`
170+
/// install.
171+
pub fn channel_label_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str {
172+
if channel != InstallChannel::Npm {
173+
return channel_label(channel);
174+
}
175+
match node_manager(canonical_exe) {
176+
NodeManager::Npm => "npm",
177+
NodeManager::Pnpm => "pnpm",
178+
NodeManager::Vlt | NodeManager::Vlx => "vlt",
179+
}
180+
}
181+
182+
/// The npm-family manager that owns a `node_modules` install.
183+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184+
enum NodeManager {
185+
Npm,
186+
Pnpm,
187+
Vlt,
188+
Vlx,
189+
}
190+
191+
/// Classify the owner of the `node_modules` install at `path`. vlt is only
192+
/// recognized for project installs (its lockfile beside the outermost
193+
/// `node_modules`, or vlx's cache project); pnpm by its `.pnpm` virtual
194+
/// store in the canonical path (every isolated-linker install, global or
195+
/// project), a `pnpm` home above a `global` store, or a `pnpm-lock.yaml`
196+
/// beside the outermost `node_modules` (`node-linker=hoisted`). Anything
197+
/// else is npm, which also covers yarn and Bun (no dedicated hint yet).
198+
fn node_manager(path: &Path) -> NodeManager {
199+
let holder = outermost_node_modules_holder(path);
200+
if !is_global_npm_install(path) {
137201
if holder.is_some_and(is_vlx_cache_dir) {
138-
return "vlx -y -- @socketsecurity/socket-patch@latest …";
202+
return NodeManager::Vlx;
139203
}
140204
if holder.is_some_and(|dir| dir.join(crate::constants::npm_family::VLT_LOCK).is_file()) {
141-
return "vlt install @socketsecurity/socket-patch@latest";
205+
return NodeManager::Vlt;
142206
}
143-
return "npm install @socketsecurity/socket-patch@latest";
144207
}
145-
upgrade_hint(channel)
208+
if has_component(path, ".pnpm")
209+
|| is_pnpm_global_store(path)
210+
|| holder.is_some_and(|dir| dir.join(crate::constants::npm_family::PNPM_LOCK).is_file())
211+
{
212+
return NodeManager::Pnpm;
213+
}
214+
NodeManager::Npm
215+
}
216+
217+
/// A `global` store below a `pnpm` home (`~/Library/pnpm/global/5`,
218+
/// `~/.local/share/pnpm/global/v11/<hash>`, `%LOCALAPPDATA%\pnpm\global\5`).
219+
fn is_pnpm_global_store(path: &Path) -> bool {
220+
let names: Vec<&std::ffi::OsStr> = path
221+
.components()
222+
.filter_map(|c| match c {
223+
Component::Normal(os) => Some(os),
224+
_ => None,
225+
})
226+
.collect();
227+
let first_nm = names
228+
.iter()
229+
.position(|n| *n == "node_modules")
230+
.unwrap_or(names.len());
231+
names[..first_nm]
232+
.iter()
233+
.position(|n| *n == "global")
234+
.is_some_and(|g| names[..g].iter().any(|n| *n == "pnpm"))
146235
}
147236

148237
/// vlx installs each package into its own project dir whose generated
@@ -199,7 +288,8 @@ fn is_global_npm_install(path: &Path) -> bool {
199288
}
200289
}
201290

202-
/// Short human label for refusal messages ("managed by npm").
291+
/// Short human label for refusal messages ("managed by npm"); see
292+
/// [`channel_label_for`] for the npm-family manager of a given path.
203293
pub fn channel_label(channel: InstallChannel) -> &'static str {
204294
match channel {
205295
InstallChannel::Standalone => "standalone",
@@ -526,7 +616,6 @@ mod tests {
526616
"/usr/local/lib/node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch",
527617
"/home/u/.nvm/versions/node/v20.1.0/lib/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch",
528618
"/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch",
529-
"/Users/u/Library/pnpm/global/5/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch",
530619
];
531620
for p in global {
532621
assert_eq!(
@@ -647,6 +736,101 @@ mod tests {
647736
);
648737
}
649738

739+
/// #1111: a pnpm install upgrades through pnpm. `npm update -g` is a
740+
/// no-op for a pnpm global store, and `npm install` in a pnpm project
741+
/// writes a stray package-lock.json and leaves pnpm-lock.yaml stale.
742+
#[test]
743+
fn pnpm_global_installs_get_pnpm_add_g() {
744+
let global = [
745+
// pnpm 9/10, macOS default PNPM_HOME.
746+
"/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",
747+
// The store root itself, as the old table row listed it.
748+
"/Users/u/Library/pnpm/global/5/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch",
749+
// pnpm 12 (`global/v11/<hash>`), Linux default PNPM_HOME.
750+
"/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",
751+
// A custom PNPM_HOME without a `pnpm` component: the virtual
752+
// store (`.pnpm`) still names the manager.
753+
"/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",
754+
];
755+
for p in global {
756+
let p = Path::new(p);
757+
assert_eq!(
758+
upgrade_hint_for(InstallChannel::Npm, p),
759+
"pnpm add -g @socketsecurity/socket-patch@latest",
760+
"{}",
761+
p.display()
762+
);
763+
assert_eq!(channel_label_for(InstallChannel::Npm, p), "pnpm");
764+
}
765+
// Yarn's global store is not pnpm's.
766+
let yarn = Path::new(
767+
"/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch",
768+
);
769+
assert_eq!(
770+
upgrade_hint_for(InstallChannel::Npm, yarn),
771+
"npm update -g @socketsecurity/socket-patch"
772+
);
773+
assert_eq!(channel_label_for(InstallChannel::Npm, yarn), "npm");
774+
}
775+
776+
#[test]
777+
fn pnpm_project_installs_get_pnpm_add() {
778+
let isolated = "node_modules/.pnpm/@socketsecurity+socket-patch-linux-x64@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch";
779+
let hoisted = "node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch";
780+
781+
// The default isolated linker: the `.pnpm` virtual store decides,
782+
// even before the first `pnpm install` wrote a lockfile.
783+
let project = tempfile::tempdir().unwrap();
784+
std::fs::write(project.path().join("package.json"), "{}").unwrap();
785+
let p = project.path().join(isolated);
786+
assert_eq!(
787+
upgrade_hint_for(InstallChannel::Npm, &p),
788+
"pnpm add @socketsecurity/socket-patch@latest"
789+
);
790+
assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm");
791+
// `node-linker=hoisted` has no `.pnpm`: pnpm-lock.yaml decides.
792+
let p = project.path().join(hoisted);
793+
assert_eq!(
794+
upgrade_hint_for(InstallChannel::Npm, &p),
795+
"npm install @socketsecurity/socket-patch@latest"
796+
);
797+
std::fs::write(project.path().join("pnpm-lock.yaml"), "").unwrap();
798+
assert_eq!(
799+
upgrade_hint_for(InstallChannel::Npm, &p),
800+
"pnpm add @socketsecurity/socket-patch@latest"
801+
);
802+
assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm");
803+
804+
// A workspace root refuses a bare `pnpm add` (ERR_PNPM_ADDING_TO_ROOT)
805+
// and the dependency may be declared by any member, so update it
806+
// wherever it is declared.
807+
let ws = tempfile::tempdir().unwrap();
808+
std::fs::write(ws.path().join("package.json"), "{}").unwrap();
809+
std::fs::write(ws.path().join("pnpm-workspace.yaml"), "packages: [a]\n").unwrap();
810+
let p = ws.path().join(isolated);
811+
assert_eq!(
812+
upgrade_hint_for(InstallChannel::Npm, &p),
813+
"pnpm update --recursive --latest @socketsecurity/socket-patch"
814+
);
815+
}
816+
817+
#[test]
818+
fn labels_name_the_owning_node_manager() {
819+
let project = tempfile::tempdir().unwrap();
820+
std::fs::write(project.path().join("package.json"), "{}").unwrap();
821+
let bin = project
822+
.path()
823+
.join("node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch");
824+
assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "npm");
825+
std::fs::write(project.path().join("vlt-lock.json"), "{}").unwrap();
826+
assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "vlt");
827+
// Non-npm channels keep their path-independent label.
828+
assert_eq!(
829+
channel_label_for(InstallChannel::Homebrew, &bin),
830+
channel_label(InstallChannel::Homebrew)
831+
);
832+
}
833+
650834
#[cfg(windows)]
651835
#[test]
652836
fn npm_hint_windows_global_prefix() {

‎crates/socket-patch-core/src/update/mod.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ pub mod swap;
1919
use std::path::{Path, PathBuf};
2020

2121
pub use channel::{
22-
channel_label, detect_channel, upgrade_hint, upgrade_hint_for, ChannelEnv, InstallChannel,
22+
channel_label, channel_label_for, detect_channel, upgrade_hint, upgrade_hint_for, ChannelEnv,
23+
InstallChannel,
2324
};
2425
pub use release::{
2526
asset_name_for_target, current_version, fetch_latest_version, is_newer, parse_release_tag,

0 commit comments

Comments
 (0)