From 30d0b5c97860d693cc37d877a1e6e553b7abf147 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 04:23:55 +0000 Subject: [PATCH 1/4] Start fix for #1621 Assisted-by: Claude Code:claude-opus-5-5 From 74a5c86075c0c87f2771ea62fe4e4c859b709ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 04:34:44 +0000 Subject: [PATCH 2/4] Point Bun installs at Bun's upgrade command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `socket-patch --update` on a copy installed with Bun said it was "managed by npm" and suggested an npm command. For a `bun add -g` install that command is a no-op, and in a Bun project `npm install` writes a stray package-lock.json and breaks the next `bun install --frozen-lockfile`. The passive update notice showed the same wrong hint. Bun installs are now labelled "bun" and told to run: - `bun add -g @socketsecurity/socket-patch@latest` for Bun's global store (`$BUN_INSTALL/install/global`), - `bun add @socketsecurity/socket-patch@latest` in a project with bun.lock / bun.lockb or Bun's isolated `node_modules/.bun` store, - `bunx @socketsecurity/socket-patch@latest …` for bunx's cache dir. Fixes #1621 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/update/self_update_channels_e2e.rs | 48 ++++ .../socket-patch-core/src/update/channel.rs | 214 ++++++++++++++++-- 3 files changed, 248 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 70f83ee43..9a521c850 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1115,7 +1115,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf | Detected channel | Hint | |---|---| -| 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` | +| npm family (`node_modules` path component) | The refusal names the owning manager (`managed by npm` / `pnpm` / `bun` / `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`. **Bun** (checked after pnpm: Bun's `install/global` store, a `.bun` isolated-linker store component after the first `node_modules`, or `bun.lock` / `bun.lockb` beside the outermost `node_modules`): global store: `bun add -g @socketsecurity/socket-patch@latest` (`bun update -g` keeps the exact version the global manifest pins); bunx cache (that directory, or for a scoped spec its parent, is named `bunx--…`): `bunx @socketsecurity/socket-patch@latest …`; project: `bun add @socketsecurity/socket-patch@latest`. **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/tests/update/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs index e65a0356b..7199c0cc2 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 @@ -110,6 +110,54 @@ async fn pnpm_global_refuses_with_pnpm_hint() { install.assert_binary_intact(); } +/// #1621: a Bun project install refuses with Bun's own command. Running +/// `npm install …` there writes a stray package-lock.json and leaves +/// bun.lock stale, so the next `bun install --frozen-lockfile` fails. +#[tokio::test] +async fn bun_project_refuses_with_bun_hint() { + let install = staged_install_at("app/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/bun.lock"), "").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 bun"), "{stderr}"); + assert!( + stderr.contains("`bun add @socketsecurity/socket-patch@latest`"), + "a Bun project must get Bun's upgrade command: {stderr}" + ); + assert!(!stderr.contains("npm install"), "{stderr}"); + install.assert_binary_intact(); +} + +/// #1621: Bun's global store (`$BUN_INSTALL/install/global`) is invisible +/// to `npm update -g`, so a Bun global install must be told to use +/// `bun add -g`. +#[tokio::test] +async fn bun_global_refuses_with_bun_hint() { + let install = + staged_install_at(".bun/install/global/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 bun"), "{stdout}"); + assert!( + message.contains("`bun 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 263a8c975..b87afa4c4 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -141,6 +141,12 @@ pub fn upgrade_hint(channel: InstallChannel) -> &'static str { /// 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`. +/// - Bun: `bun add -g …@latest` for its global store (`bun update -g` +/// keeps the exact version Bun's global manifest pins), `bun add +/// …@latest` in a project, and bunx's cache dir (`bunx--`) is +/// refreshed by running bunx with `@latest`. npm's commands fail here the +/// same way they do for pnpm, and `npm install` breaks the next +/// `bun install --frozen-lockfile`. pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { if channel != InstallChannel::Npm { return upgrade_hint(channel); @@ -158,7 +164,10 @@ pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'stat "pnpm update --recursive --latest @socketsecurity/socket-patch" } NodeManager::Pnpm => "pnpm add @socketsecurity/socket-patch@latest", + NodeManager::Bun if global => "bun add -g @socketsecurity/socket-patch@latest", + NodeManager::Bun => "bun add @socketsecurity/socket-patch@latest", _ if global => upgrade_hint(channel), + NodeManager::Bunx => "bunx @socketsecurity/socket-patch@latest …", NodeManager::Vlx => "vlx -y -- @socketsecurity/socket-patch@latest …", NodeManager::Vlt => "vlt install @socketsecurity/socket-patch@latest", NodeManager::Npm => "npm install @socketsecurity/socket-patch@latest", @@ -175,6 +184,7 @@ pub fn channel_label_for(channel: InstallChannel, canonical_exe: &Path) -> &'sta match node_manager(canonical_exe) { NodeManager::Npm => "npm", NodeManager::Pnpm => "pnpm", + NodeManager::Bun | NodeManager::Bunx => "bun", NodeManager::Vlt | NodeManager::Vlx => "vlt", } } @@ -184,6 +194,8 @@ pub fn channel_label_for(channel: InstallChannel, canonical_exe: &Path) -> &'sta enum NodeManager { Npm, Pnpm, + Bun, + Bunx, Vlt, Vlx, } @@ -193,14 +205,20 @@ enum NodeManager { /// `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). +/// beside the outermost `node_modules` (`node-linker=hoisted`). Bun by +/// its `install/global` store, a `.bun` isolated-linker store inside +/// `node_modules`, or a `bun.lock` / `bun.lockb` beside the outermost +/// `node_modules`; bunx by its cache dir's name. Anything else is npm, +/// which also covers yarn (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 NodeManager::Vlx; } + if holder.is_some_and(is_bunx_cache_dir) { + return NodeManager::Bunx; + } if holder.is_some_and(|dir| dir.join(crate::constants::npm_family::VLT_LOCK).is_file()) { return NodeManager::Vlt; } @@ -211,19 +229,75 @@ fn node_manager(path: &Path) -> NodeManager { { return NodeManager::Pnpm; } + if is_bun_global_store(path) + || has_component_after_node_modules(path, ".bun") + || holder.is_some_and(|dir| { + [ + crate::constants::npm_family::BUN_LOCK, + crate::constants::npm_family::BUN_LOCKB, + ] + .iter() + .any(|lock| dir.join(lock).is_file()) + }) + { + return NodeManager::Bun; + } 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() +/// Bun's global store, `$BUN_INSTALL/install/global/node_modules` +/// (`~/.bun/install/global`, `%USERPROFILE%\.bun\install\global`). +fn is_bun_global_store(path: &Path) -> bool { + let names = normal_components(path); + names + .iter() + .position(|n| *n == "node_modules") + .is_some_and(|nm| nm >= 2 && names[nm - 1] == "global" && names[nm - 2] == "install") +} + +/// Whether `name` is a component inside the first `node_modules` of `path` +/// (Bun's isolated linker keeps its store at `node_modules/.bun/`; a `.bun` +/// above it is `$BUN_INSTALL`, which [`is_bun_global_store`] covers). +fn has_component_after_node_modules(path: &Path, name: &str) -> bool { + let names = normal_components(path); + names + .iter() + .position(|n| *n == "node_modules") + .is_some_and(|nm| names[nm + 1..].iter().any(|n| *n == name)) +} + +/// bunx installs each spec into its own project dir, +/// `$TMPDIR/bunx--`; a scoped spec splits it in two +/// (`bunx--@scope/name@version`). +fn is_bunx_cache_dir(dir: &Path) -> bool { + let is_bunx = |d: &Path| { + d.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("bunx-")) + }; + is_bunx(dir) + || dir.parent().is_some_and(|parent| { + is_bunx(parent) + && parent + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("-@")) + }) +} + +fn normal_components(path: &Path) -> Vec<&std::ffi::OsStr> { + path.components() .filter_map(|c| match c { Component::Normal(os) => Some(os), _ => None, }) - .collect(); + .collect() +} + +/// 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 = normal_components(path); let first_nm = names .iter() .position(|n| *n == "node_modules") @@ -266,13 +340,7 @@ fn outermost_node_modules_holder(path: &Path) -> Option<&Path> { /// `npm install …` run from an arbitrary cwd would scaffold a stray /// `node_modules`/`package.json` there and leave the real copy stale. fn is_global_npm_install(path: &Path) -> bool { - let names: Vec<&std::ffi::OsStr> = path - .components() - .filter_map(|c| match c { - Component::Normal(os) => Some(os), - _ => None, - }) - .collect(); + let names = normal_components(path); let Some(first_nm) = names.iter().position(|n| *n == "node_modules") else { return false; }; @@ -814,6 +882,122 @@ mod tests { ); } + /// #1621: a Bun install upgrades through Bun. `npm update -g` never + /// sees Bun's global store, and `npm install` in a Bun project writes a + /// stray package-lock.json and leaves bun.lock stale, so the next + /// `bun install --frozen-lockfile` fails. + #[test] + fn bun_global_installs_get_bun_add_g() { + let global = [ + // The default `$BUN_INSTALL` (`~/.bun`). + "/home/u/.bun/install/global/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", + "/Users/u/.bun/install/global/node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", + // A custom `$BUN_INSTALL` without a `.bun` component. + "/opt/bunhome/install/global/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", + ]; + for p in global { + let p = Path::new(p); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, p), + "bun add -g @socketsecurity/socket-patch@latest", + "{}", + p.display() + ); + assert_eq!(channel_label_for(InstallChannel::Npm, p), "bun"); + } + // Neither npm's `lib` prefix nor yarn's global store is Bun's. + for p in [ + "/usr/local/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", + ] { + let p = Path::new(p); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, p), + "npm update -g @socketsecurity/socket-patch" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, p), "npm"); + } + } + + #[test] + fn bun_project_installs_get_bun_add() { + let hoisted = "node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"; + let isolated = "node_modules/.bun/@socketsecurity+socket-patch-linux-x64-gnu@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"; + + // Bun's isolated linker: the `.bun` store decides, lock or not. + 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), + "bun add @socketsecurity/socket-patch@latest" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + // The hoisted linker: the text or binary lockfile decides. + let p = project.path().join(hoisted); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "npm install @socketsecurity/socket-patch@latest" + ); + for lock in ["bun.lock", "bun.lockb"] { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("package.json"), "{}").unwrap(); + std::fs::write(project.path().join(lock), "").unwrap(); + let p = project.path().join(hoisted); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "bun add @socketsecurity/socket-patch@latest", + "{lock}" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + } + // A lock in a nested dir between the holder and the binary is not + // the holder's. + let nested = tempfile::tempdir().unwrap(); + std::fs::write(nested.path().join("package.json"), "{}").unwrap(); + std::fs::create_dir_all(nested.path().join("node_modules/x")).unwrap(); + std::fs::write(nested.path().join("node_modules/x/bun.lock"), "").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &nested.path().join(hoisted)), + "npm install @socketsecurity/socket-patch@latest" + ); + } + + /// bunx installs each spec into `$TMPDIR/bunx--`, a project + /// dir with its own package.json and lock; a scoped spec nests one + /// level (`bunx-0-@scope/name@version`). Re-running bunx with `@latest` + /// is what refreshes it, while `bun add` there would edit the cache. + #[test] + fn bunx_cache_gets_bunx_latest() { + let bin = "node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"; + let tmp = tempfile::tempdir().unwrap(); + for dir in [ + "bunx-0-@socketsecurity/socket-patch@4.0.0", + "bunx-1000-socket-patch-shim@1.0.0", + ] { + let holder = tmp.path().join(dir); + std::fs::create_dir_all(&holder).unwrap(); + std::fs::write(holder.join("package.json"), "{}").unwrap(); + std::fs::write(holder.join("bun.lock"), "").unwrap(); + let p = holder.join(bin); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "bunx @socketsecurity/socket-patch@latest …", + "{dir}" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + } + // A project merely named like the cache but deeper is a Bun project. + let holder = tmp.path().join("bunx-0-x/apps/web"); + std::fs::create_dir_all(&holder).unwrap(); + std::fs::write(holder.join("package.json"), "{}").unwrap(); + std::fs::write(holder.join("bun.lock"), "").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &holder.join(bin)), + "bun add @socketsecurity/socket-patch@latest" + ); + } + #[test] fn labels_name_the_owning_node_manager() { let project = tempfile::tempdir().unwrap(); From 26461c3bd0890e79c0773336ef9ead01224e3595 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 04:56:33 +0000 Subject: [PATCH 3/4] Keep bunx and .bun store ahead of other signals A bunx cache dir is now recognized by its name even when it holds no package.json (which would otherwise read as a global prefix and get `bun add -g`). A project-only `bunx-...` name no longer matches: the uid part must be numeric, as bunx writes it. A `.bun` or `.pnpm` store in the binary's path now outranks a lockfile left beside the project, so a project that moved from pnpm to Bun and still has pnpm-lock.yaml is told to use Bun. Refs #1621 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-core/src/update/channel.rs | 102 +++++++++++------- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 9a521c850..7ec4eb450 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1115,7 +1115,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf | Detected channel | Hint | |---|---| -| npm family (`node_modules` path component) | The refusal names the owning manager (`managed by npm` / `pnpm` / `bun` / `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`. **Bun** (checked after pnpm: Bun's `install/global` store, a `.bun` isolated-linker store component after the first `node_modules`, or `bun.lock` / `bun.lockb` beside the outermost `node_modules`): global store: `bun add -g @socketsecurity/socket-patch@latest` (`bun update -g` keeps the exact version the global manifest pins); bunx cache (that directory, or for a scoped spec its parent, is named `bunx--…`): `bunx @socketsecurity/socket-patch@latest …`; project: `bun add @socketsecurity/socket-patch@latest`. **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` | +| npm family (`node_modules` path component) | The refusal names the owning manager (`managed by npm` / `pnpm` / `bun` / `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`. **Bun** (Bun's `install/global` store, a `.bun` isolated-linker store component after the first `node_modules`, or `bun.lock` / `bun.lockb` beside the outermost `node_modules`; a store component in the path outranks a lockfile, and pnpm's lockfile outranks Bun's): bunx cache (that directory, or for a scoped spec its parent, is named `bunx--…`): `bunx @socketsecurity/socket-patch@latest …`; global store: `bun add -g @socketsecurity/socket-patch@latest` (`bun update -g` keeps the exact version the global manifest pins); project: `bun add @socketsecurity/socket-patch@latest`. **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-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index b87afa4c4..b958d33a3 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -166,8 +166,8 @@ pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'stat NodeManager::Pnpm => "pnpm add @socketsecurity/socket-patch@latest", NodeManager::Bun if global => "bun add -g @socketsecurity/socket-patch@latest", NodeManager::Bun => "bun add @socketsecurity/socket-patch@latest", - _ if global => upgrade_hint(channel), NodeManager::Bunx => "bunx @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", @@ -212,33 +212,33 @@ enum NodeManager { /// which also covers yarn (no dedicated hint yet). fn node_manager(path: &Path) -> NodeManager { let holder = outermost_node_modules_holder(path); + // bunx's cache is told by its dir name alone, whether or not the dir + // still holds a package.json. + if holder.is_some_and(is_bunx_cache_dir) { + return NodeManager::Bunx; + } if !is_global_npm_install(path) { if holder.is_some_and(is_vlx_cache_dir) { return NodeManager::Vlx; } - if holder.is_some_and(is_bunx_cache_dir) { - return NodeManager::Bunx; - } if holder.is_some_and(|dir| dir.join(crate::constants::npm_family::VLT_LOCK).is_file()) { return NodeManager::Vlt; } } - 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()) - { + // A store in the path names the manager that wrote it, ahead of any + // lockfile left beside the project (e.g. mid-migration). + if has_component(path, ".pnpm") || is_pnpm_global_store(path) { return NodeManager::Pnpm; } - if is_bun_global_store(path) - || has_component_after_node_modules(path, ".bun") - || holder.is_some_and(|dir| { - [ - crate::constants::npm_family::BUN_LOCK, - crate::constants::npm_family::BUN_LOCKB, - ] - .iter() - .any(|lock| dir.join(lock).is_file()) - }) + if is_bun_global_store(path) || has_component_after_node_modules(path, ".bun") { + return NodeManager::Bun; + } + let holder_has = |lock: &str| holder.is_some_and(|dir| dir.join(lock).is_file()); + if holder_has(crate::constants::npm_family::PNPM_LOCK) { + return NodeManager::Pnpm; + } + if holder_has(crate::constants::npm_family::BUN_LOCK) + || holder_has(crate::constants::npm_family::BUN_LOCKB) { return NodeManager::Bun; } @@ -267,22 +267,20 @@ fn has_component_after_node_modules(path: &Path, name: &str) -> bool { } /// bunx installs each spec into its own project dir, -/// `$TMPDIR/bunx--`; a scoped spec splits it in two -/// (`bunx--@scope/name@version`). +/// `$TMPDIR/bunx--` with a numeric uid; a scoped spec splits it +/// in two (`bunx--@scope/name@version`). fn is_bunx_cache_dir(dir: &Path) -> bool { - let is_bunx = |d: &Path| { - d.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("bunx-")) - }; - is_bunx(dir) - || dir.parent().is_some_and(|parent| { - is_bunx(parent) - && parent - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.contains("-@")) - }) + // The `` part of `bunx--`. + fn bunx_spec(dir: &Path) -> Option<&str> { + let rest = dir.file_name()?.to_str()?.strip_prefix("bunx-")?; + let (uid, spec) = rest.split_once('-')?; + (!uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit())).then_some(spec) + } + bunx_spec(dir).is_some_and(|spec| !spec.is_empty()) + || dir + .parent() + .and_then(bunx_spec) + .is_some_and(|scope| scope.len() > 1 && scope.starts_with('@')) } fn normal_components(path: &Path) -> Vec<&std::ffi::OsStr> { @@ -933,6 +931,14 @@ mod tests { "bun add @socketsecurity/socket-patch@latest" ); assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + // The `.bun` store is Bun's even beside a pnpm lock left over from + // a migration. + std::fs::write(project.path().join("pnpm-lock.yaml"), "").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "bun add @socketsecurity/socket-patch@latest" + ); + std::fs::remove_file(project.path().join("pnpm-lock.yaml")).unwrap(); // The hoisted linker: the text or binary lockfile decides. let p = project.path().join(hoisted); assert_eq!( @@ -987,15 +993,35 @@ mod tests { ); assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); } - // A project merely named like the cache but deeper is a Bun project. - let holder = tmp.path().join("bunx-0-x/apps/web"); + // A cache dir without its package.json (which would otherwise read + // as a global prefix) is still bunx's. + let holder = tmp + .path() + .join("bunx-501-@socketsecurity/socket-patch@4.0.0"); std::fs::create_dir_all(&holder).unwrap(); - std::fs::write(holder.join("package.json"), "{}").unwrap(); - std::fs::write(holder.join("bun.lock"), "").unwrap(); + std::fs::write(holder.join("bun.lockb"), "").unwrap(); assert_eq!( upgrade_hint_for(InstallChannel::Npm, &holder.join(bin)), - "bun add @socketsecurity/socket-patch@latest" + "bunx @socketsecurity/socket-patch@latest …" ); + // A Bun project merely named like the cache, or nested below one + // without a scope level, is a Bun project. + for dir in [ + "bunx-tools", + "bunx-x-y", + "bunx-0-x/apps", + "bunx-0-x/apps/web", + ] { + let holder = tmp.path().join(dir); + std::fs::create_dir_all(&holder).unwrap(); + std::fs::write(holder.join("package.json"), "{}").unwrap(); + std::fs::write(holder.join("bun.lock"), "").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &holder.join(bin)), + "bun add @socketsecurity/socket-patch@latest", + "{dir}" + ); + } } #[test] From 251cd210dd90b6ec52dddc4e24e95848d95577f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:27:17 +0000 Subject: [PATCH 4/4] Pin uv 0.13.1 for the Pipenv pylock legs (port of #1639) The merge queue dropped this PR because `pipenv::pipenv_pylock_projects_wire_what_pipenv_installs` failed in the e2e_vex_build pipenv leg. uv 0.13.2+ refuses the `environments = []` pylock that Pipenv 2026.8.0 writes ("The target environment is not supported by the lockfile"), and setup-uv without `version:` installs the newest uv. main's nightly is red for the same reason. This is the same pin #1639 adds to ci.yml and pipenv-compatibility.yml. It changes nothing once #1639 lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016akFSbKa8ztJC4jji31cAn --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/pipenv-compatibility.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eef9a64dd..764e0bbbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1465,6 +1465,12 @@ jobs: - name: Setup uv (Pipenv / pip legs) if: matrix.pipenv != '' || matrix.pip != '' uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 + with: + # Pin uv: it is the PEP 751 oracle the pylock tests install with, + # and uv 0.13.2 started refusing Pipenv's `environments = []` + # pylock ("target environment is not supported by the lockfile"). + # Bump deliberately, after checking the Pipenv pylock legs pass. + version: "0.13.1" - name: Install the Pythons the Pipenv / pip legs run on if: matrix.pipenv != '' || matrix.pip != '' diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index cea9c0e5e..a786e3276 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -101,6 +101,12 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install uv uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + # Pin uv: it is the PEP 751 oracle the pylock tests install with, + # and uv 0.13.2 started refusing Pipenv's `environments = []` + # pylock ("target environment is not supported by the lockfile"). + # Bump deliberately, after checking the Pipenv pylock legs pass. + version: "0.13.1" - name: Install the Pythons the Pipenv releases run on run: uv python install 3.8 3.12 - name: Build the CLI