diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5fde8db0d..90184bb66 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -175,7 +175,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. So is a vendor ledger beside hosted pins in the project's lockfiles (#1437), the mixed state a partial `--mode hosted` takeover or per-package `get --mode` leaves: hosted mode keeps no ledger, so its pins are its state, and keeping vendored mode would take every pin over. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. **Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 83bc76bb6..1c0be1078 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -58,7 +58,13 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool { /// * a manifest (`.socket/manifest.json`) holding patches → agent; /// * neither → hosted (hosted mode keeps no ledger of its own); /// * both → `Err(usage message)`: the run cannot tell which mode to keep, -/// so it asks for an explicit `--mode` rather than guess. +/// so it asks for an explicit `--mode` rather than guess; +/// * a vendor ledger beside hosted pins in the lockfiles (a partial +/// `--mode hosted` takeover, or per-package `get --mode`) → +/// `Err(usage message)` too: hosted mode keeps no ledger, so its pins +/// are its state, and keeping vendored mode would take every one of +/// them over (#1437). The lockfiles are only walked beside a non-empty +/// ledger, the one case where a pin changes the answer. /// /// Both stores are read from [`GlobalArgs::project_root`], so a /// `--manifest-path` into another project consults that project's ledger, @@ -108,7 +114,45 @@ pub(crate) async fn mode_from_project_state( project_root.display(), manifest_path.display(), )), - (true, false) => Ok(scan::ScanMode::Vendored), + (true, false) => { + // Lockless cargo/nuget pins and Gemfile-only gem pins (a + // CHECKSUMS-less lock, #1224) are never refs, so + // `HostedPin::recorded` drops them; they are hosted state too. + let discovery = discover_wiring(common, &project_root).await; + let gemfile_pins = socket_patch_core::vex::discover::gem_manifest_source_pins( + &socket_patch_core::vendor::lock_inventory::ProjectView::Disk(&project_root), + ) + .await; + let mut purls: Vec = + socket_patch_core::patch::redirect::upstream::HostedPin::recorded(&discovery) + .into_iter() + .map(|pin| pin.purl) + .chain( + discovery + .unlocked_pins + .iter() + .map(|pin| format!("pkg:{}/{}", pin.ecosystem, pin.name)), + ) + .chain(gemfile_pins.into_iter().map(|(purl, _)| purl)) + .collect(); + if purls.is_empty() { + return Ok(scan::ScanMode::Vendored); + } + purls.sort_unstable(); + purls.dedup(); + let more = purls.len().saturating_sub(3); + purls.truncate(3); + let mut named = purls.join(", "); + if more > 0 { + named.push_str(&format!(" and {more} more")); + } + Err(format!( + "{} holds both hosted pins in its dependency files ({named}) and vendored \ + patches (.socket/vendor/state.json): pass --mode hosted, --mode vendored \ + or --mode agent to choose the mode this run uses", + project_root.display(), + )) + } (false, true) => Ok(scan::ScanMode::Agent), (false, false) => Ok(scan::ScanMode::Hosted), } @@ -454,6 +498,117 @@ mod tests { assert!(mode_from_project_state(&common).await.is_err()); } + /// A `package-lock.json` at `root` pinning `name@version` to Socket's + /// hosted patch server: hosted state, which keeps no ledger. + fn write_hosted_lock(root: &Path, name: &str, version: &str) { + let lock = serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "0.0.0" }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/tok/{UUID}/\ + {name}-{version}.tgz" + ), + "integrity": format!("sha512-{}", "a".repeat(86)), + }, + }, + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + } + + /// Vendored packages beside hosted pins (a partial `--mode hosted` + /// takeover, or per-package `get --mode`): a bare run cannot keep both + /// modes, and picking vendored would take every hosted pin over + /// (#1437), so it asks for `--mode`. + #[tokio::test] + async fn hosted_pins_beside_a_ledger_are_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_hosted_lock(tmp.path(), "minimist", "1.2.2"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and hosted state together"); + assert!(err.contains("hosted"), "{err}"); + assert!(err.contains("--mode"), "{err}"); + } + + /// A hosted pin over the very package the ledger holds is still a + /// hosted pin: a bare vendored run would convert it back. + #[tokio::test] + async fn a_hosted_pin_over_a_ledger_entry_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + write_hosted_lock(tmp.path(), "left-pad", "1.3.0"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert!(mode.is_err(), "{mode:?}"); + } + + /// A lockless hosted pin (a nuget source no lock records yet) is + /// hosted state too, though `HostedPin::recorded` never lists it. + #[tokio::test] + async fn a_lockless_hosted_pin_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + let key = format!("socket-patch-{UUID}"); + std::fs::write( + tmp.path().join("nuget.config"), + format!( + "\n\n \ + \n \n \n \ + \n \n \ + \n \n \ + \n\n" + ), + ) + .unwrap(); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and lockless hosted state together"); + assert!(err.contains("pkg:nuget/Newtonsoft.Json"), "{err}"); + } + + /// A gem pinned only in the Gemfile (the hosted rewriter's wiring over + /// a CHECKSUMS-less lock, #1224) has no lock ref yet, but it is hosted + /// state all the same. + #[tokio::test] + async fn a_gemfile_only_hosted_pin_beside_a_ledger_is_ambiguous() { + let tmp = tempfile::tempdir().unwrap(); + write_ledger(tmp.path()).await; + std::fs::write( + tmp.path().join("Gemfile"), + format!( + "source \"https://rubygems.org\"\n\nsource \"https://patch.socket.dev/\ + patch-registry/gem/tok/{UUID}/\" do\n gem \"rails\", \"7.0.0\"\nend\n" + ), + ) + .unwrap(); + std::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)\n", + ) + .unwrap(); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + let err = mode.expect_err("vendored and Gemfile-only hosted state together"); + assert!(err.contains("pkg:gem/rails@7.0.0"), "{err}"); + } + + /// Hosted pins alone are a hosted project, and need no ledger. + #[tokio::test] + async fn hosted_pins_alone_are_hosted() { + let tmp = tempfile::tempdir().unwrap(); + write_hosted_lock(tmp.path(), "minimist", "1.2.2"); + let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await; + assert_eq!(mode, Ok(scan::ScanMode::Hosted)); + } + /// With `--manifest-path` into another project, the ledger is read /// from that project, not from `--cwd`: a ledger left in `--cwd` is /// not consulted, and the other project's ledger is. diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index 95b097e8f..fdd59869a 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -455,6 +455,79 @@ async fn bare_scan_and_get_refuse_a_project_with_agent_and_vendored_state() { } } +/// A package of the project pinned to Socket's hosted patch server beside +/// the vendored `left-pad`: the mixed state a partial `--mode hosted` +/// takeover (#1039) or per-package `get --mode` leaves. +const HOSTED_NAME: &str = "is-odd"; +const HOSTED_VERSION: &str = "3.0.1"; +const HOSTED_PIN_URL: &str = "https://patch.socket.dev/patch/npm/is-odd/3.0.1/55555555-5555-4555-8555-555555555555/22222222-2222-4222-8222-222222222222/is-odd-3.0.1.tgz"; + +/// Add `is-odd`, pinned hosted, to the vendored project at `root`. +fn add_hosted_pin(root: &Path) { + let mut pkg: Value = + serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap(); + pkg["dependencies"][HOSTED_NAME] = json!(HOSTED_VERSION); + std::fs::write(root.join("package.json"), pkg.to_string()).unwrap(); + let installed = root.join("node_modules").join(HOSTED_NAME); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("package.json"), + format!(r#"{{"name":"{HOSTED_NAME}","version":"{HOSTED_VERSION}"}}"#), + ) + .unwrap(); + let mut lock: Value = + serde_json::from_slice(&std::fs::read(root.join("package-lock.json")).unwrap()).unwrap(); + lock["packages"][""]["dependencies"][HOSTED_NAME] = json!(HOSTED_VERSION); + lock["packages"][format!("node_modules/{HOSTED_NAME}")] = json!({ + "version": HOSTED_VERSION, + "resolved": HOSTED_PIN_URL, + "integrity": PATCHED_SHA512, + }); + let mut text = serde_json::to_string_pretty(&lock).unwrap(); + text.push('\n'); + std::fs::write(root.join("package-lock.json"), text).unwrap(); +} + +/// Vendored packages beside hosted pins (#1437): keeping vendored mode +/// would take every hosted pin over, which only an explicit +/// `--mode vendored` may do. The run is a usage error naming `--mode`, +/// and nothing is written. +#[tokio::test(flavor = "multi_thread")] +async fn bare_scan_and_get_refuse_a_project_with_vendored_and_hosted_state() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendor_project(root, &server.uri()); + add_hosted_pin(root); + let before = snapshot(root); + + for command in ["scan", "get"] { + let (code, env) = run_cmd(root, &server.uri(), command, None, &[]); + assert_eq!(code, 2, "{command}: {env:#}"); + assert_eq!(env["status"], "error", "{command}: {env:#}"); + assert!( + env.to_string().contains("mode_ambiguous"), + "{command}: the error carries its code: {env:#}" + ); + assert!( + env.to_string().contains("--mode"), + "{command}: the error names the remedy: {env:#}" + ); + assert_unchanged(root, &before, &env); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!( + lock.contains(HOSTED_PIN_URL), + "{command}: the hosted pin is kept:\n{lock}" + ); + } + + // An explicit `--mode` picks the mode: the run is not refused. + let (code, env) = run_cmd(root, &server.uri(), "scan", Some("vendored"), &[]); + assert_eq!(code, 0, "an explicit mode is not ambiguous: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); +} + /// An agent-mode manifest holding the fixture patch under each of `purls`. fn write_manifest(root: &Path, purls: &[&str]) { std::fs::create_dir_all(root.join(".socket")).unwrap(); diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index b1bdc70a7..74d67fd87 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -18,8 +18,9 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and vendor ledger (`.socket/vendor/state.json`) stays vendored, and one whose `.socket/manifest.json` holds patches stays in agent mode. Switching modes needs an explicit `--mode` (for example `scan --mode hosted` converts a vendored project in - place). A project holding both agent and vendored patches must pass `--mode` - (`mode_ambiguous`, exit 2). Global scans and a mode-less + place). A project holding both agent and vendored patches, or vendored patches + beside hosted pins in its lockfiles, must pass `--mode` (`mode_ambiguous`, + exit 2). Global scans and a mode-less `scan --prune` do not acquire new patches; `--prune` still performs cleanup. - Hosted state lives in dependency files. No command writes `.socket/vendor/redirect-state.json`. Legacy hosted records can still supply