Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<cache>/socket-patch/bin/…`) | `gem uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) |
Expand Down
6 changes: 3 additions & 3 deletions crates/socket-patch-cli/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 {
Expand All @@ -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
),
);
Expand Down
51 changes: 51 additions & 0 deletions crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
220 changes: 202 additions & 18 deletions crates/socket-patch-core/src/update/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<prefix>/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
/// (`<project>/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 (`<prefix>/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 (`<project>/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/<hash>`, `%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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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/<hash>`), 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() {
Expand Down
3 changes: 2 additions & 1 deletion crates/socket-patch-core/src/update/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading