Skip to content
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 @@ -1271,7 +1271,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed <code>` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail `<purl> was hosted; restored its upstream registry entry (<files>) before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. |
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. |
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
| `redirect_pnpm_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. |
| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |
Expand Down
140 changes: 140 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1457,3 +1457,143 @@ async fn hosted_scan_from_pnpm_member_respects_root_trust_opt_out() {
"{warnings}"
);
}

/// An npm / yarn / Bun workspace: the root `package.json` lists
/// `packages/*` under `workspaces` (array form, or yarn classic's
/// `{packages, nohoist}` object form) and holds the only lock, `lock_name`;
/// the member `packages/a` holds its manifest and its own unhoisted copy.
fn write_package_json_workspace(
root: &Path,
lock_name: &str,
object_form: bool,
) -> std::path::PathBuf {
let workspaces = if object_form {
r#"{ "packages": ["packages/*"], "nohoist": ["**/in-proc-redirect-pnpm"] }"#
} else {
r#"["packages/*"]"#
};
std::fs::write(
root.join("package.json"),
format!(r#"{{ "name": "root", "private": true, "workspaces": {workspaces} }}"#),
)
.unwrap();
std::fs::write(root.join(lock_name), format!("# root lock {lock_name}\n")).unwrap();
let member = root.join("packages/a");
let pkg = member.join("node_modules").join(NAME);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
member.join("package.json"),
format!(
r#"{{ "name": "a", "version": "1.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"#
),
)
.unwrap();
std::fs::write(
pkg.join("package.json"),
format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#),
)
.unwrap();
member
}

/// #884: `scan --mode hosted` and `get <uuid> --mode hosted` from an npm,
/// yarn classic, yarn berry or Bun workspace member found the member's
/// copy, read no lock in the member, pinned nothing, and exited 0 with
/// `success` and an npm "no package-lock.json" warning, while the package
/// manager installs the unpatched copy from the root lock. They now refuse
/// and name the workspace root.
#[tokio::test]
#[serial]
async fn hosted_scan_from_package_json_workspace_member_refuses() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
for (lock_name, object_form) in [
("package-lock.json", false),
("npm-shrinkwrap.json", false),
("yarn.lock", false),
("yarn.lock", true),
("bun.lock", false),
("bun.lockb", false),
] {
let tmp = tempfile::tempdir().unwrap();
let member = write_package_json_workspace(tmp.path(), lock_name, object_form);
let lock = tmp.path().join(lock_name);
let before = std::fs::read_to_string(&lock).unwrap();
let case = format!("{lock_name} (object form: {object_form})");

let (code, doc) = run_hosted_json(&member, &server.uri());
assert_refused_workspace_lock_elsewhere(&case, code, &doc, &lock, &before, &member);

let out = scrubbed_cli()
.args([
"get",
UUID,
"--mode",
"hosted",
"--json",
"--yes",
"--cwd",
member.to_str().unwrap(),
"--api-url",
&server.uri(),
"--org",
ORG,
"--api-token",
"fake",
])
.output()
.expect("run socket-patch");
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"{case}: get --json output is not JSON ({e}):\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
});
assert_refused_workspace_lock_elsewhere(
&case,
out.status.code(),
&doc,
&lock,
&before,
&member,
);
}
}

fn assert_refused_workspace_lock_elsewhere(
case: &str,
code: Option<i32>,
doc: &serde_json::Value,
lock: &Path,
lock_before: &str,
cwd: &Path,
) {
assert_eq!(
code,
Some(1),
"{case}: a found-but-unpinnable patch is not success: {doc}"
);
assert_eq!(doc["status"], "error", "{case}: {doc}");
assert_eq!(
doc["errorCode"], "redirect_workspace_lockfile_elsewhere",
"{case}: {doc}"
);
let message = doc["error"].as_str().unwrap_or_default();
let lock_name = lock.file_name().unwrap().to_str().unwrap();
assert!(
message.contains(lock_name) && message.contains("nothing was written"),
"{case}: the error names the governing lock: {message}"
);
assert_eq!(
std::fs::read_to_string(lock).unwrap(),
lock_before,
"{case}"
);
assert!(
!cwd.join(".socket").exists(),
"{case}: nothing written in the member"
);
}
Loading
Loading