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: 2 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v<N>/<hash>/node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v<N>` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names.

**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm/<registry>/<name>/<version>/`, where `<registry>` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `<host>/api/npm`) (scoped: `@scope/name/<version>/`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding some package dir in Deno's layout: no `package.json` of its own, and a cached `registry.json` or a version dir with a `package.json`) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`<version>_<n>/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix.

**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts.

**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry their own `redirect` payload and `details.mode: "hosted"` events instead, and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale.
Expand Down
58 changes: 58 additions & 0 deletions crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1006,3 +1006,61 @@ fn rollback_dispatch_branch_deno() {
};
assert_rollback_restored(root, "deno", &fixture);
}

/// #444: a package `deno install -g` tools load lives in Deno's npm cache,
/// `$DENO_DIR/npm/registry.npmjs.org/<name>/<version>/`. Both `--global`
/// (which now discovers that cache from `DENO_DIR`) and a `--global-prefix`
/// naming the registry dir must reach it; before the fix each reported the
/// package not installed.
fn deno_npm_cache_fixture(
root: &Path,
scope_args: impl FnOnce(&Path) -> Vec<String>,
) -> RollbackFixture {
let deno_dir = root.join("deno-dir");
let registry = deno_dir.join("npm").join("registry.npmjs.org");
let pkg = registry.join("__deno_global_dispatch__").join("1.0.0");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("package.json"),
r#"{"name":"__deno_global_dispatch__","version":"1.0.0"}"#,
)
.unwrap();
// Deno caches the package's packument beside its version dirs.
std::fs::write(pkg.parent().unwrap().join("registry.json"), "{}").unwrap();
let verify_file = pkg.join("index.js");
std::fs::write(&verify_file, PATCHED).unwrap();
let purl = "pkg:npm/__deno_global_dispatch__@1.0.0";
write_rollback_manifest(root, purl, "package/index.js");
RollbackFixture {
purl: purl.to_string(),
verify_file,
envs: vec![(
"DENO_DIR".to_string(),
deno_dir.to_str().unwrap().to_string(),
)],
scope_args: scope_args(&registry),
}
}

#[test]
fn rollback_dispatch_deno_npm_cache_global() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_root_package_json(root);
let fixture = deno_npm_cache_fixture(root, |_| vec!["--global".to_string()]);
assert_rollback_restored(root, "npm", &fixture);
}

#[test]
fn rollback_dispatch_deno_npm_cache_global_prefix() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_root_package_json(root);
let fixture = deno_npm_cache_fixture(root, |registry| {
vec![
"--global-prefix".to_string(),
registry.to_str().unwrap().to_string(),
]
});
assert_rollback_restored(root, "npm", &fixture);
}
49 changes: 46 additions & 3 deletions crates/socket-patch-core/src/crawlers/deno_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ impl DenoCrawler {

/// Get the JSR cache root paths to scan.
///
/// With `--global-prefix`, returns the prefix directly; otherwise
/// resolves `$DENO_DIR/npm/jsr.io/`.
/// With `--global-prefix`, returns the prefix directly (nothing for a
/// registry dir of Deno's npm cache, which holds npm packages);
/// otherwise resolves `$DENO_DIR/npm/jsr.io/`.
///
/// In local mode, only returns paths when the cwd looks like a
/// Deno project (`deno.json`, `deno.jsonc`, or `deno.lock`
Expand All @@ -71,6 +72,13 @@ impl DenoCrawler {
options: &CrawlerOptions,
) -> Result<Vec<PathBuf>, std::io::Error> {
if let Some(ref custom) = options.global_prefix {
// Deno's npm cache (`$DENO_DIR/npm/registry.npmjs.org`) holds
// npm packages, which the npm crawler reads there; walked as a
// JSR root, its `@scope/name/<version>` dirs would read as
// bogus `pkg:jsr/` purls.
if super::npm_crawler::is_deno_npm_cache_root(custom) {
return Ok(Vec::new());
}
return Ok(vec![custom.clone()]);
}
if !options.global && !is_deno_project(&options.cwd).await {
Expand Down Expand Up @@ -336,7 +344,7 @@ async fn is_deno_project(cwd: &Path) -> bool {
/// * Linux/other Unix: `$XDG_CACHE_HOME/deno`, else `$HOME/.cache/deno`.
/// * Windows: `%LOCALAPPDATA%\deno` (falling back to `~\.cache\deno`
/// if LOCALAPPDATA isn't set).
fn deno_dir() -> Option<PathBuf> {
pub(super) fn deno_dir() -> Option<PathBuf> {
if let Ok(d) = std::env::var("DENO_DIR") {
if !d.is_empty() {
return Some(PathBuf::from(d));
Expand Down Expand Up @@ -422,6 +430,41 @@ mod tests {
let _b = DenoCrawler::new();
}

/// A `--global-prefix` naming Deno's npm cache registry dir is the npm
/// crawler's (#444): its scoped `@types/node/20.0.0` must not also
/// surface as `pkg:jsr/@types/node@20.0.0`. A JSR tree under a dir
/// named `npm` (no `registry.json`) is still walked.
#[tokio::test]
async fn crawl_all_skips_deno_npm_cache_prefix() {
let tmp = tempfile::tempdir().unwrap();
let registry = tmp.path().join("npm").join("registry.npmjs.org");
stage(&registry, "@types", "node", "20.0.0").await;
tokio::fs::write(registry.join("@types/node/registry.json"), b"{}")
.await
.unwrap();
let opts = CrawlerOptions {
cwd: tmp.path().to_path_buf(),
global: true,
global_prefix: Some(registry),
};
assert!(DenoCrawler.crawl_all(&opts).await.is_empty());

let mirror = tmp.path().join("npm").join("jsr-mirror");
stage(&mirror, "@std", "path", "1.0.8").await;
let opts = CrawlerOptions {
cwd: tmp.path().to_path_buf(),
global: true,
global_prefix: Some(mirror),
};
let purls: Vec<String> = DenoCrawler
.crawl_all(&opts)
.await
.into_iter()
.map(|p| p.purl)
.collect();
assert_eq!(purls, vec!["pkg:jsr/@std/path@1.0.8"]);
}

#[tokio::test]
async fn crawl_all_empty_cache_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading