diff --git a/.buildkite/linux-agent.Dockerfile b/.buildkite/linux-agent.Dockerfile index 6cd69f115..f629fcde0 100644 --- a/.buildkite/linux-agent.Dockerfile +++ b/.buildkite/linux-agent.Dockerfile @@ -17,10 +17,51 @@ RUN apt-get update \ xz-utils \ && rm -rf /var/lib/apt/lists/* -RUN curl https://mise.run | sh +# -f + explicit installer file: a piped `curl | sh` with no -f would let a +# network-level curl failure produce an empty script and a silently +# mise-less image; verifying the binary afterwards fails the layer loudly. +RUN set -eux; \ + curl --proto '=https' --tlsv1.2 -fsSL https://mise.run -o /tmp/mise-install.sh; \ + sh /tmp/mise-install.sh; \ + rm /tmp/mise-install.sh; \ + /root/.local/bin/mise --version +# Pre-bake the repo's toolchains with MINIMAL profiles + only the components +# jobs use (the default profile ships rust-docs, ~600 MB per toolchain): +# - nightly-2026-07-04: the rust-toolchain.toml pin (rustfmt/clippy for +# lint jobs, llvm-tools-preview for the PGO flow) +# - 1.95.0: the msrv floor cargo-msrv verifies against +# - stable: rustup's default for anything outside the repo checkout RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile default --default-toolchain stable \ - && /root/.cargo/bin/rustup toolchain install 1.93.0 --profile default \ - && /root/.cargo/bin/rustup component add rustfmt clippy --toolchain stable + | sh -s -- -y --profile minimal --default-toolchain stable \ + && /root/.cargo/bin/rustup toolchain install nightly-2026-07-04 --profile minimal \ + -c rustfmt -c clippy -c llvm-tools-preview \ + && /root/.cargo/bin/rustup toolchain install 1.95.0 --profile minimal \ + && rm -rf /root/.rustup/downloads /root/.rustup/tmp -ENV PATH="/root/.cargo/bin:/root/.local/bin:/root/.local/share/mise/shims:${PATH}" +# Pre-bake Socket Firewall (free tier — keyless; jobs holding a Socket key +# select enterprise at job time) plus its package-manager shims, pinned + +# integrity-checked against external-tools.json (sfw-free 1.13.1, sha512 +# hex below = that file's SRI decoded). +COPY sfw-shim-template.sh /tmp/sfw-shim-template.sh +RUN set -eux; \ + arch="$(uname -m)"; \ + case "$arch" in \ + x86_64) asset=sfw-free-linux-x86_64; sha=c1a2ebb0f1b66bb10ebf45eebd70d0646802678313b4e7d987c4e619b33a827d81e8d87a1c8fb5e636a829d012f7081d4f222a4eea94f8ef8db5561dfa4b8568 ;; \ + aarch64) asset=sfw-free-linux-arm64; sha=158458479d922fe28a165756e288183949d31f9392aafb57c331857968b45b6c7eef929970f886cd605b84c9a1d1fe50b6060b57ee2a75112098a08111ac8db4 ;; \ + *) echo "unsupported arch $arch" >&2; exit 1 ;; \ + esac; \ + mkdir -p /root/.local/share/aube/dev-tools/rack/sfw-free/1.13.1 /root/.local/share/aube/dev-tools/bin; \ + curl -fsSL -o /root/.local/share/aube/dev-tools/rack/sfw-free/1.13.1/sfw \ + "https://github.com/SocketDev/sfw-free/releases/download/v1.13.1/$asset"; \ + echo "$sha /root/.local/share/aube/dev-tools/rack/sfw-free/1.13.1/sfw" | sha512sum -c -; \ + chmod 0755 /root/.local/share/aube/dev-tools/rack/sfw-free/1.13.1/sfw; \ + ln -sf /root/.local/share/aube/dev-tools/rack/sfw-free/1.13.1/sfw /root/.local/share/aube/dev-tools/bin/sfw; \ + for cmd in npm yarn pnpm pip pip3 uv cargo; do \ + sentinel="SFW_SHIM_ACTIVE_$(printf '%s' "$cmd" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_')"; \ + sed -e "s/__CMD__/$cmd/g" -e "s/__SENTINEL__/$sentinel/g" \ + /tmp/sfw-shim-template.sh > "/root/.local/share/aube/dev-tools/bin/$cmd"; \ + chmod 0755 "/root/.local/share/aube/dev-tools/bin/$cmd"; \ + done; \ + rm /tmp/sfw-shim-template.sh + +ENV PATH="/root/.local/share/aube/dev-tools/bin:/root/.cargo/bin:/root/.local/bin:/root/.local/share/mise/shims:${PATH}" diff --git a/.buildkite/sfw-shim-template.sh b/.buildkite/sfw-shim-template.sh new file mode 100644 index 000000000..84e81e93d --- /dev/null +++ b/.buildkite/sfw-shim-template.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# sfw shim for __CMD__ — pre-baked into the agent image; mirrors what +# scripts/soak/external-tools.mts --shims writes on dev machines. Routes the +# real package-manager invocation through Socket Firewall; the sentinel env +# var breaks recursion when sfw re-invokes the tool, and the real binary is +# found by stripping the tool-rack bin dir out of PATH. +set -euo pipefail +CLEAN_PATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vFx '/root/.local/share/aube/dev-tools/bin' | paste -sd ':' -) +REAL=$(PATH="$CLEAN_PATH" command -v '__CMD__' || true) +if [ -n "${__SENTINEL__:-}" ] || [ -z "$REAL" ] || ! command -v sfw >/dev/null 2>&1; then + [ -n "$REAL" ] && exec "$REAL" "$@" + echo "__CMD__: not found" >&2 + exit 127 +fi +export __SENTINEL__=1 +exec sfw '__CMD__' "$@" diff --git a/.cargo/config.toml b/.cargo/config.toml index 77940a30b..5064cef7b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -15,3 +15,15 @@ # (7.07 s → 9.57 s) — acceptable given the safety guarantee. [env] RUST_TEST_THREADS = "1" + +# Supply-chain soak for cargo's own dependency resolution — the same +# soak-window rule aube offers npm users via minimumReleaseAge: crate +# versions younger than the window are pubtime-incompatible and the +# resolver skips them unless already in Cargo.lock +# (resolver.incompatible-publish-age defaults to "deny"). The unstable +# feature is active because rust-toolchain.toml pins a nightly. +[unstable] +min-publish-age = true + +[registry] +global-min-publish-age = "7 days" diff --git a/.claude/skills/soak/SKILL.md b/.claude/skills/soak/SKILL.md new file mode 100644 index 000000000..c27ea3533 --- /dev/null +++ b/.claude/skills/soak/SKILL.md @@ -0,0 +1,89 @@ +--- +name: soak +description: Manages the repo's supply-chain soak window (SOAK_DAYS) — checks and fixes the derived surfaces, bumps or disables the window, adds dated per-package exclusions, and bumps pinned external tools. Use when a task touches minimumReleaseAge, min-release-age, min-publish-age, the nightly toolchain pin, external-tools.json, renovate.json, or taze cooldowns, or when investigating why a freshly published version won't install. +--- + +# The soak window + +One rule: a release must be at least `SOAK_DAYS` old before this repo +adopts it. The delay gives the ecosystem time to catch a malicious or +yanked release before we ever install it. The window is defined exactly +once — read the current value from `scripts/soak/constants.mts` and never +hardcode it elsewhere. Every surface derives from or is parity-checked +against it: + +| Surface | Key | Units | +|---|---|---| +| `.cargo/config.toml` | `global-min-publish-age` | `"N days"` | +| `rust-toolchain.toml` | nightly channel date vs `# adopted:` line | days | +| `docs/pnpm-workspace.yaml` | `minimumReleaseAge` | minutes | +| `docs/.npmrc` | `min-release-age` | days | +| `docs/taze.config.mts` | `maturityPeriod` | imports `SOAK_DAYS` | +| `external-tools.json` | `soakBypass` annotations | days | +| `.github/renovate.json` | `minimumReleaseAge` (explicit — an `extends:` preset doesn't count) | `"N days"` | + +## Commands (mise tasks — the scripts live in `scripts/soak/`) + +- `mise run soak` — parity-check every surface (CI-gated in test-linux) +- `mise run soak:fix` — rewrite drifted windows, prune expired exclusions +- `mise run deps:update` — bump npm (taze) + cargo deps through the window +- `mise run tools:check` / `tools:install` — validate / install the + SRI-pinned external tools (`external-tools.json`) +- `mise run test:scripts` — the scripts' own unit tests + +A soak change is done when `mise run soak` and `mise run test:scripts` +both exit 0 — the same gates CI runs. Re-run them after every fix. + +## Change the window (one place) + +1. Edit `SOAK_DAYS` in `scripts/soak/constants.mts`. +2. `mise run soak:fix` (rewrites cargo/npmrc/yaml; taze follows by import). +3. `mise run soak` + `mise run test:scripts` — existing exclusion + annotations encode the old window and will be flagged; re-date or + remove them, then re-run until both pass. + +**Opt out entirely**: set `SOAK_DAYS = 0` and run the same two steps — +cargo, pnpm/aube (`minimumReleaseAge: 0`), npm, and taze all treat zero as +disabled. There is deliberately no env-var bypass: opting out is a +committed, reviewable change, never a silent one. + +## Skip the soak for ONE package (dated, temporary) + +Add to `minimumReleaseAgeExclude` in `docs/pnpm-workspace.yaml` with the +annotation on the line above (block list only — flow `[..]` is rejected +because a comment line can't attach to an inline entry): + +```yaml +# published: 2026-07-08 | removable: 2026-07-15 +- 'name@1.2.3' +``` + +`removable` = `published + SOAK_DAYS` (this example assumes a 7-day +window). `published` must be the real registry publish date. Once +`removable` passes, `mise run soak` fails until the pin is pruned +(`soak:fix` does it). Bare names / `@scope/*` globs are standing trust and +need no annotation. External tools use the same shape via a `soakBypass` +object in `external-tools.json`. + +## Bump the nightly toolchain + +Pick the newest nightly at least `SOAK_DAYS` old **today**, set it as +`channel`, and update the `# adopted: ` line in +`rust-toolchain.toml` — `mise run soak` enforces the arithmetic. Also bump +the pre-baked toolchain in `.buildkite/linux-agent.Dockerfile` +(`mise run tools:check` fails on any drift between the image and the +tracked pins). + +## Maintaining this skill + +`scripts/soak/` is the law; this file only documents it — when they +disagree, fix this file. When editing, follow Anthropic's guidance: + +- [Prompting best practices](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices) +- [Prompting Claude Fable 5](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-fable-5) +- [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) +- [Write an effective CLAUDE.md](https://code.claude.com/docs/en/best-practices#write-an-effective-claude-md) + +Keep it concise (goal + constraints, not step enumeration), keep the +description in third person with explicit "use when" triggers, and keep +the window value in `constants.mts` rather than restating it here. diff --git a/.github/renovate.json b/.github/renovate.json index f94204c37..112bad3a4 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,8 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["local>jdx/renovate-config"], + "minimumReleaseAge": "7 days", + "internalChecksFilter": "strict", "ignorePaths": ["fixtures/**"], "packageRules": [ { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92792543b..eea854cb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,16 @@ jobs: contents: read env: RUSTFLAGS: "-D warnings" + # Until the SOCKET_SECURITY_KEY repo secret lands this is empty and + # sfw runs in its keyless free tier — the firewall is active either + # way and upgrades itself to enterprise when the secret appears. + SOCKET_SECURITY_KEY: ${{ secrets.SOCKET_SECURITY_KEY }} + # cargo test spins LOCAL registry mocks; routing `cargo` through the + # sfw shim would wrap that traffic in the firewall proxy and mangle + # it (405s on registry writes, bodies replaced by proxy errors). The + # sentinel makes the cargo shim exec the real cargo directly — + # npm/pnpm installs stay firewalled. + SFW_SHIM_ACTIVE_CARGO: "1" steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -82,6 +92,19 @@ jobs: - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 with: cache: false + - name: soak + external-tool pin gates + run: | + mise run soak + mise run tools:check + mise run test:scripts + - name: pinned package managers + socket firewall + run: | + node scripts/soak/external-tools.mts --install pnpm --install npm --install sfw --shims + node scripts/soak/external-tools.mts --print-bin >> "$GITHUB_PATH" + - name: assert pinned pms resolve through the shims + run: | + pnpm --version + npm --version - run: node benchmarks/validate-results.mts - run: mise run test - run: mise run lint diff --git a/.gitignore b/.gitignore index 529a8cb2e..85d06f22f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ /scratch/ /sandbox/ /bench-install.sh -/.claude/ +# Local agent state stays untracked, but committed skills are allowed: +# git cannot re-include children of an excluded DIRECTORY, so exclude the +# dir's contents (/*) and negate the skills subtree. +/.claude/* +!/.claude/skills/ /crates/aube-resolver/data/primer-top*.json /crates/aube-resolver/data/primer-top*.rkyv.zst diff --git a/.rules b/.rules index da4a6197c..ca9349bce 100644 --- a/.rules +++ b/.rules @@ -3,7 +3,7 @@ project: kind: Node.js package manager, Rust workspace binary_surface: drop-in for pnpm CLI language: Rust 2024 - msrv: 1.93 + msrv: 1.95 (stable floor; builds use the dated nightly pinned in rust-toolchain.toml, adopted after a 7-day soak, so PGO instrument+use share one LLVM) async_runtime: tokio (multi-thread for install, current-thread for nested islands) errors: miette (user-facing, top-level), thiserror (per-library crate) error_codes: every error/warning carries a stable ERR_AUBE_*/WARN_AUBE_* identifier from aube-codes crate; bespoke exit codes for a curated subset; see error_codes section below for conventions @@ -187,7 +187,7 @@ testing: rust: critical[5]: - - edition 2024, latest stable toolchain, msrv 1.93 (rust 1.90 default-LLD-on-Linux + workspace cargo publish available) + - edition 2024, toolchain pinned to a dated nightly in rust-toolchain.toml (adopt the newest nightly >=7 days old, same soak rule for bumps), msrv 1.95 stable floor (core::hint::cold_path stable; rust 1.90 default-LLD-on-Linux + workspace cargo publish available) - no .unwrap()/.expect() in public library APIs or cross-crate boundaries. local-invariant unwraps where panic is genuinely impossible are ok, annotate with WHY - crates/aube-*/ (libraries) use tracing (info!/warn!/error!/debug!/trace!). crates/aube/src/commands/*.rs CLI command impls may print to stdout via println!/writeln! when the whole point is CLI output (view, list, outdated, why, cat-index, etc.) - cargo clippy -- -D warnings must pass with zero warnings @@ -202,7 +202,7 @@ rust: - shared reqwest::Client with pool, never one per request (aube-registry/src/client.rs does this right) - DashMap for concurrent maps, OnceLock/LazyLock for init-once globals, rayon par_iter for CPU-bound parallel passes off the tokio runtime (never spawn tokio tasks for pure CPU) - pre-size Vec/HashMap when bounds are known; SmallVec<[T; N]> for hot paths with known-small size; Arc/Box over String when immutable - - aube already adopts mimalloc (global allocator), sonic_rs (packument decode), BLAKE3 (non-crypto hashing). these are settled; don't re-propose them + - aube already adopts mimalloc (global allocator), sonic_rs (packument decode), BLAKE3 (non-crypto hashing), core::hint::cold_path() on rare arms of hot install loops (linker materialize, pnpm subset parser, semver caches, tar walk). these are settled; don't re-propose them async[7]: - tokio multi-thread runtime for install (main.rs:586), nested current_thread islands inside spawn_blocking are fine - never block in async fn, offload via spawn_blocking (IO that can't be async, tarball decode, tar extract) @@ -234,9 +234,24 @@ deps: policy: minimize, prefer std over external, every added dep must justify >=20 LOC saved tls: rustls (no OpenSSL / native-tls) reqwest_features: minimal (rustls-tls + http2 + json + stream + charset + system-proxy) - supply_chain[2]: + supply_chain[4]: - cargo audit: CVE database scan (RustSec advisories) before every release, required in CI - cargo deny: license policy, banned crates, duplicate detection, approved sources. deny.toml checked in + - cargo min-publish-age: 7-day soak on new crate versions (.cargo/config.toml [registry] global-min-publish-age; unstable feature, active via the pinned nightly). resolver skips versions younger than the window unless already locked + - external tools: pnpm/npm/sfw/zizmor/agentshield/skillspector pinned exact with sha512 SRI in external-tools.json; installed/verified only via scripts/soak/external-tools.mts (mise run tools:install / tools:check) + +soak: + window: SOAK_DAYS=7 in scripts/soak/constants.mts — the ONE source; every surface derives or is parity-checked against it + surfaces[6]: + - .cargo/config.toml global-min-publish-age ("7 days") + - rust-toolchain.toml dated nightly (channel date vs the machine-read `# adopted:` line — soak.mts enforces adopted >= channel + SOAK_DAYS) + - docs/pnpm-workspace.yaml minimumReleaseAge (10080 minutes; also the taze catalog: home — aube reads this file for docs installs, and a ROOT pnpm-workspace.yaml would re-anchor the docs install at repo root, so it must stay in docs/) + - docs/.npmrc min-release-age (7 days, npm >= 11.17 parity) + - docs/taze.config.mts maturityPeriod (imports SOAK_DAYS) + - .github/renovate.json minimumReleaseAge ("7 days", explicit — extends: presets don't count; renovate PRs commit updated lockfiles and cargo min-publish-age skips already-locked versions, so renovate itself is the only place this dep path can be soaked) + manager: .claude/skills/soak/SKILL.md is the how-to; mise run soak (check, CI-gated in test-linux) / soak:fix (syncs windows, prunes expired excludes) / deps:update (taze + cargo update, both soaked) + excludes: version-pinned minimumReleaseAgeExclude entries REQUIRE `# published: YYYY-MM-DD | removable: YYYY-MM-DD` on the line above (removable = published + 7d); bare names/globs are standing trust and need no annotation; expired pins are pruned by soak:fix + socket_firewall: CI installs sfw (free tier keyless; enterprise auto-selected when the SOCKET_SECURITY_KEY repo secret lands — the one env var every Socket product reads, no aliasing) and shims npm/yarn/pnpm/pip/pip3/uv/cargo via scripts/soak/external-tools.mts --shims; the buildkite agent image pre-bakes the same binary + shims git: forbidden[2]: diff --git a/Cargo.toml b/Cargo.toml index 8d6fc1b97..e36c6907b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ codegen-units = 1 [workspace.package] version = "1.26.0" edition = "2024" -rust-version = "1.93" +rust-version = "1.95" license = "MIT" repository = "https://github.com/jdx/aube" homepage = "https://github.com/jdx/aube" diff --git a/crates/aube-linker/src/materialize.rs b/crates/aube-linker/src/materialize.rs index 60f166ca6..ef6e7312b 100644 --- a/crates/aube-linker/src/materialize.rs +++ b/crates/aube-linker/src/materialize.rs @@ -561,6 +561,7 @@ impl Linker { let target = pkg_nm_dir.join(rel_path); if let Err(e) = self.link_file_fresh(stored, rel_path, &target) { + core::hint::cold_path(); if let Error::MissingStoreFile { .. } = &e { invalidate_stale_index_for_package(&self.store, pkg); } @@ -785,6 +786,7 @@ impl Linker { } }; if let Err(e) = reflink_result { + core::hint::cold_path(); // Source-missing short-circuit avoids the misleading // "fell back to copy" trace and the redundant copy // attempt that would just ENOENT for the same reason. @@ -840,6 +842,7 @@ impl Linker { } LinkStrategy::Hardlink => { if let Err(e) = std::fs::hard_link(&stored.store_path, dst) { + core::hint::cold_path(); if !stored.store_path.exists() { return Err(missing_source()); } diff --git a/crates/aube-lockfile/src/pnpm/subset.rs b/crates/aube-lockfile/src/pnpm/subset.rs index 41fc67d80..9090b6d74 100644 --- a/crates/aube-lockfile/src/pnpm/subset.rs +++ b/crates/aube-lockfile/src/pnpm/subset.rs @@ -224,7 +224,10 @@ impl<'a> Parser<'a> { } // Any top-level key we don't recognize: bail to serde so // we never silently drop a field a future pnpm adds. - _ => return None, + _ => { + core::hint::cold_path(); + return None; + } } } @@ -304,6 +307,7 @@ impl<'a> Parser<'a> { break; } if line.indent != min_indent { + core::hint::cold_path(); return None; } let (k, v) = split_key(line.body)?; @@ -350,6 +354,7 @@ impl<'a> Parser<'a> { break; } if line.indent != 2 { + core::hint::cold_path(); return None; } // `:` header. pnpm writes an empty importer @@ -395,7 +400,10 @@ impl<'a> Parser<'a> { b"devDependencies" => dev_dependencies = Some(specs), b"optionalDependencies" => optional_dependencies = Some(specs), b"skippedOptionalDependencies" => skipped_optional_dependencies = Some(specs), - _ => return None, + _ => { + core::hint::cold_path(); + return None; + } } } Some(RawImporter { @@ -415,6 +423,7 @@ impl<'a> Parser<'a> { break; } if line.indent != entry_indent { + core::hint::cold_path(); return None; } let (name, rest) = split_key(line.body)?; @@ -432,6 +441,7 @@ impl<'a> Parser<'a> { break; } if c.indent != child_indent { + core::hint::cold_path(); return None; } let (ck, cv) = split_key(c.body)?; @@ -440,7 +450,10 @@ impl<'a> Parser<'a> { match ck { b"specifier" => specifier = Some(scalar_string(cv)?), b"version" => version = Some(scalar_string(cv)?), - _ => return None, + _ => { + core::hint::cold_path(); + return None; + } } } map.insert( @@ -468,6 +481,7 @@ impl<'a> Parser<'a> { break; } if line.indent != 2 { + core::hint::cold_path(); return None; } let (key, rest) = split_key(line.body)?; @@ -510,6 +524,7 @@ impl<'a> Parser<'a> { break; } if line.indent != 2 { + core::hint::cold_path(); return None; } let (key, rest) = split_key(line.body)?; @@ -540,6 +555,7 @@ impl<'a> Parser<'a> { break; } if line.indent != indent { + core::hint::cold_path(); return None; } let (key, rest) = split_key(line.body)?; @@ -560,7 +576,10 @@ impl<'a> Parser<'a> { b"transitivePeerDependencies" => { transitive_peer_dependencies = Some(self.parse_seq_value(rest, indent + 2)?); } - _ => return None, + _ => { + core::hint::cold_path(); + return None; + } } } Some(RawSnapshot { @@ -637,6 +656,7 @@ fn split_key(body: &[u8]) -> Option<(&[u8], Option<&[u8]>)> { } i += 1; } + core::hint::cold_path(); None } diff --git a/crates/aube-lockfile/src/source.rs b/crates/aube-lockfile/src/source.rs index 662dea2df..abb9f9e21 100644 --- a/crates/aube-lockfile/src/source.rs +++ b/crates/aube-lockfile/src/source.rs @@ -248,10 +248,9 @@ impl LocalSource { ("link", r) } else if let Some(r) = spec.strip_prefix("portal:") { ("portal", r) - } else if let Some(r) = spec.strip_prefix("exec:") { - return Some(LocalSource::Exec(PathBuf::from(r))); } else { - return None; + let r = spec.strip_prefix("exec:")?; + return Some(LocalSource::Exec(PathBuf::from(r))); }; let rel = PathBuf::from(rest); let abs = project_root.join(&rel); diff --git a/crates/aube-resolver/src/semver_util.rs b/crates/aube-resolver/src/semver_util.rs index 4dc152fb4..a5b829ec3 100644 --- a/crates/aube-resolver/src/semver_util.rs +++ b/crates/aube-resolver/src/semver_util.rs @@ -353,6 +353,7 @@ fn with_cached_range(range_str: &str, f: impl FnOnce(Option<&node_semver::Ran CACHE.with(|cell| { let mut map = cell.borrow_mut(); if !map.contains_key(range_str) { + core::hint::cold_path(); let parsed = node_semver::Range::parse(range_str).ok(); map.insert(range_str.to_string(), parsed); } @@ -371,6 +372,7 @@ fn with_cached_version(version: &str, f: impl FnOnce(Option<&node_semver::Ver CACHE.with(|cell| { let mut map = cell.borrow_mut(); if !map.contains_key(version) { + core::hint::cold_path(); let parsed = node_semver::Version::parse(version).ok(); map.insert(version.to_string(), parsed); } diff --git a/crates/aube-store/src/tarball.rs b/crates/aube-store/src/tarball.rs index 17ca6a3ed..8d5f03170 100644 --- a/crates/aube-store/src/tarball.rs +++ b/crates/aube-store/src/tarball.rs @@ -188,6 +188,7 @@ impl Store { for entry in archive.entries().map_err(|e| Error::Tar(e.to_string()))? { entries_seen += 1; if entries_seen > MAX_TARBALL_ENTRIES { + core::hint::cold_path(); return Err(Error::Tar(format!( "tarball exceeds entry cap of {MAX_TARBALL_ENTRIES}" ))); @@ -226,6 +227,7 @@ impl Store { entry_type, tar::EntryType::Regular | tar::EntryType::Continuous ) { + core::hint::cold_path(); return Err(Error::Tar(format!( "tarball entry type {entry_type:?} is not allowed" ))); @@ -240,6 +242,7 @@ impl Store { .size() .map_err(|e| Error::Tar(e.to_string()))?; if declared > MAX_TARBALL_ENTRY_BYTES { + core::hint::cold_path(); return Err(Error::Tar(format!( "tarball entry exceeds per-entry cap: {declared} bytes > {MAX_TARBALL_ENTRY_BYTES}" ))); @@ -270,6 +273,7 @@ impl Store { // non-empty stream. Synthetic-entry injection: header // claims empty file, real bytes go to disk. if declared == 0 && !content.is_empty() { + core::hint::cold_path(); return Err(Error::Tar(format!( "tarball entry declared 0 bytes but yielded {} bytes", content.len() diff --git a/docs/.gitignore b/docs/.gitignore index 1b198bcb9..1b7b7a493 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,4 +2,3 @@ node_modules .vitepress/dist .vitepress/cache .pnpm-store -pnpm-workspace.yaml diff --git a/docs/.npmrc b/docs/.npmrc new file mode 100644 index 000000000..81cf986d0 --- /dev/null +++ b/docs/.npmrc @@ -0,0 +1,4 @@ +# npm-cli parity for the soak window (npm >= 11.17, DAYS). aube reads the +# workspace yaml (minutes) which takes precedence; this covers stray npm/npx +# usage in docs/. Managed by scripts/soak/soak.mts. +min-release-age=7 diff --git a/docs/aube-lock.yaml b/docs/aube-lock.yaml index 9943f8f71..6411799db 100644 --- a/docs/aube-lock.yaml +++ b/docs/aube-lock.yaml @@ -4,6 +4,15 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + taze: + specifier: 19.14.1 + version: 19.14.1 + untracked: + specifier: 1.6.4 + version: 1.6.4 + importers: .: @@ -15,6 +24,12 @@ importers: sharp: specifier: ^0.35.0 version: 0.35.2 + taze: + specifier: 'catalog:' + version: 19.14.1 + untracked: + specifier: 'catalog:' + version: 1.6.4 vitepress: specifier: ^1.5.0 version: 1.6.4(postcss@8.5.15) @@ -97,6 +112,11 @@ packages: resolution: {integrity: sha512-Yyyne4l//vDSdg4MhYJkaVne+KEPi833eCj3/T/87ernTwrvP6j9biXXZELsN8sLI/f2ndV/vugDIy2jdJQB6g==} engines: {node: '>= 14.0.0'} + '@antfu/ni@30.2.0': + resolution: {integrity: sha512-/FOdAP1w8COnANVD3TtNj/tnpt/36RkU/ysKZTqx86x9acdhCqTFjDXNYVDyBg6UzcrTwWPUeY75ng7CWLNr+g==} + engines: {node: '>=20.19.0'} + hasBin: true + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -161,6 +181,9 @@ packages: cpu: [x64] os: [linux] + '@henrygd/queue@1.2.0': + resolution: {integrity: sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA==} + '@iconify-json/simple-icons@1.2.87': resolution: {integrity: sha512-8YciStObhSji3OZFmWAWK6kBujyqO5bLCxeDwLxf3CR3F4PVelq7keC2LBvgTqviWzSTysj5/g4PCFLiAMVGsw==} @@ -248,6 +271,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@rollup/rollup-darwin-arm64@4.62.2': resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] @@ -306,6 +332,14 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@0.14.0': + resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} + engines: {node: '>=6'} + + '@szmarczak/http-timer@1.1.2': + resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} + engines: {node: '>=6'} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -432,32 +466,105 @@ packages: resolution: {integrity: sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==} engines: {node: '>= 14.0.0'} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cacheable-request@6.1.0: + resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==} + engines: {node: '>=8'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + configstore@5.0.1: + resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==} + engines: {node: '>=8'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + decompress-response@3.3.0: + resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} + engines: {node: '>=4'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + defer-to-connect@1.1.3: + resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -465,9 +572,22 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + duplexer3@0.1.5: + resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -477,9 +597,22 @@ packages: engines: {node: '>=12'} hasBin: true + escape-goat@2.1.1: + resolution: {integrity: sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==} + engines: {node: '>=8'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} @@ -488,6 +621,36 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + + get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + got@9.6.0: + resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==} + engines: {node: '>=8.6'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-yarn@2.1.0: + resolution: {integrity: sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==} + engines: {node: '>=8'} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -500,13 +663,91 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + import-lazy@2.1.0: + resolution: {integrity: sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==} + engines: {node: '>=4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + is-ci@2.0.0: + resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-npm@5.0.0: + resolution: {integrity: sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==} + engines: {node: '>=10'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + is-yarn-global@0.3.0: + resolution: {integrity: sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + json-buffer@3.0.0: + resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} + + keyv@3.1.0: + resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==} + + latest-version@5.1.0: + resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==} + engines: {node: '>=8'} + + lowercase-keys@1.0.1: + resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} + engines: {node: '>=0.10.0'} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -528,26 +769,79 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + normalize-url@4.5.1: + resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} + engines: {node: '>=8'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + p-cancelable@1.1.0: + resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==} + engines: {node: '>=6'} + + package-json@6.5.0: + resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} + engines: {node: '>=8'} + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pnpm-workspace-yaml@1.6.1: + resolution: {integrity: sha512-yTeZntGWi8m9WNuhoVsP0DpFc4sC1U0+rr/qR6Zi9n2g3sxXY+JfccjXjjruNz96tM8I09yaJUA86doRnNLkbg==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -555,9 +849,27 @@ packages: preact@10.29.2: resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prepend-http@2.0.0: + resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==} + engines: {node: '>=4'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pupa@2.1.1: + resolution: {integrity: sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==} + engines: {node: '>=8'} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -567,6 +879,21 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + registry-auth-token@4.2.2: + resolution: {integrity: sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==} + engines: {node: '>=6.0.0'} + + registry-url@5.1.0: + resolution: {integrity: sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==} + engines: {node: '>=8'} + + responselike@1.0.2: + resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -578,6 +905,14 @@ packages: search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver-diff@3.1.1: + resolution: {integrity: sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==} + engines: {node: '>=8'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -590,6 +925,13 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -601,19 +943,75 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + taze@19.14.1: + resolution: {integrity: sha512-+wf/IqGReU68vBE/iJ7JCuV5QeD6zQBp9MI6YphN7bT2vf/YIHd0oVA4AJiX3uANI1hQY58MrVmDwLv0x/q3BA==} + hasBin: true + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyspawn@1.5.7: + resolution: {integrity: sha512-EUrfYrlL9SvWuDjyzgF1NdILYHAOI/fosveOHbIcB9ZTaCu+IqPrA7df4M6Olf4vZUFrO9U0qPqOATLApk8WjA==} + engines: {node: '>= 20'} + + to-readable-stream@1.0.0: + resolution: {integrity: sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==} + engines: {node: '>=6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -629,6 +1027,19 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + untracked@1.6.4: + resolution: {integrity: sha512-Hd3JAnjdUYKbGpzr6xvAm9JkBw2yAHiD/dCZEh377veDHdSlnnKAHYOAvQ6UxxQCWq5KAggnPPhkg8MrQhLkRA==} + engines: {node: '>= 6'} + hasBin: true + + update-notifier@5.1.0: + resolution: {integrity: sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==} + engines: {node: '>=10'} + + url-parse-lax@3.0.0: + resolution: {integrity: sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==} + engines: {node: '>=4'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -686,6 +1097,29 @@ packages: typescript: optional: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + xdg-basedir@4.0.0: + resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} + engines: {node: '>=8'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -796,6 +1230,13 @@ snapshots: dependencies: '@algolia/client-common': 5.55.0 + '@antfu/ni@30.2.0': + dependencies: + fzf: 0.5.2 + package-manager-detector: 1.7.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -840,6 +1281,8 @@ snapshots: '@esbuild/linux-x64@0.21.5': optional: true + '@henrygd/queue@1.2.0': {} + '@iconify-json/simple-icons@1.2.87': dependencies: '@iconify/types': 2.0.0 @@ -898,6 +1341,10 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@rollup/rollup-darwin-arm64@4.62.2': optional: true @@ -956,6 +1403,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@0.14.0': {} + + '@szmarczak/http-timer@1.1.2': + dependencies: + defer-to-connect: 1.1.3 + '@types/estree@1.0.9': {} '@types/hast@3.0.4': @@ -1101,32 +1554,121 @@ snapshots: '@algolia/requester-fetch': 5.55.0 '@algolia/requester-node-http': 5.55.0 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + birpc@2.9.0: {} + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + cac@7.0.0: {} + + cacheable-request@6.1.0: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 3.1.0 + lowercase-keys: 2.0.0 + normalize-url: 4.5.1 + responselike: 1.0.2 + + camelcase@6.3.0: {} + ccount@2.0.1: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} + ci-info@2.0.0: {} + + cli-boxes@2.2.1: {} + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + configstore@5.0.1: + dependencies: + dot-prop: 5.3.0 + graceful-fs: 4.2.11 + make-dir: 3.1.0 + unique-string: 2.0.0 + write-file-atomic: 3.0.3 + xdg-basedir: 4.0.0 + copy-anything@4.0.5: dependencies: is-what: 5.5.0 + crypto-random-string@2.0.0: {} + csstype@3.2.3: {} + decompress-response@3.3.0: + dependencies: + mimic-response: 1.0.1 + + deep-extend@0.6.0: {} + + defer-to-connect@1.1.3: {} + + defu@6.1.7: {} + dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: dequal: 2.0.3 + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + duplexer3@0.1.5: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@7.0.1: {} esbuild@0.21.5: @@ -1136,8 +1678,14 @@ snapshots: '@esbuild/linux-arm64': 0.21.5 '@esbuild/linux-x64': 0.21.5 + escape-goat@2.1.1: {} + estree-walker@2.0.2: {} + fdir@6.5.0(picomatch@4.0.5): + dependencies: + picomatch: 4.0.5 + focus-trap@7.8.0: dependencies: tabbable: 6.5.0 @@ -1145,6 +1693,40 @@ snapshots: fsevents@2.3.3: optional: true + fzf@0.5.2: {} + + get-stream@4.1.0: + dependencies: + pump: 3.0.4 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + got@9.6.0: + dependencies: + '@sindresorhus/is': 0.14.0 + '@szmarczak/http-timer': 1.1.2 + cacheable-request: 6.1.0 + decompress-response: 3.3.0 + duplexer3: 0.1.5 + get-stream: 4.1.0 + lowercase-keys: 1.0.1 + mimic-response: 1.0.1 + p-cancelable: 1.1.0 + to-readable-stream: 1.0.0 + url-parse-lax: 3.0.0 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-yarn@2.1.0: {} + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -1167,12 +1749,65 @@ snapshots: html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + + import-lazy@2.1.0: {} + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + ini@2.0.0: {} + + is-ci@2.0.0: + dependencies: + ci-info: 2.0.0 + + is-fullwidth-code-point@3.0.0: {} + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-npm@5.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-typedarray@1.0.0: {} + is-what@5.5.0: {} + is-yarn-global@0.3.0: {} + + jiti@2.7.0: {} + + joycon@3.1.1: {} + + json-buffer@3.0.0: {} + + keyv@3.1.0: + dependencies: + json-buffer: 3.0.0 + + latest-version@5.1.0: + dependencies: + package-json: 6.5.0 + + lowercase-keys@1.0.1: {} + + lowercase-keys@2.0.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + mark.js@8.11.1: {} mdast-util-to-hast@13.2.1: @@ -1204,22 +1839,67 @@ snapshots: micromark-util-types@2.0.2: {} + mimic-function@5.0.1: {} + + mimic-response@1.0.1: {} + + minimist@1.2.8: {} + minisearch@7.2.0: {} mitt@3.0.1: {} + mri@1.2.0: {} + nanoid@3.3.15: {} + node-fetch-native@1.6.7: {} + + normalize-url@4.5.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-to-es@3.1.1: dependencies: emoji-regex-xs: 1.0.0 regex: 6.1.0 regex-recursion: 6.0.2 + p-cancelable@1.1.0: {} + + package-json@6.5.0: + dependencies: + got: 9.6.0 + registry-auth-token: 4.2.2 + registry-url: 5.1.0 + semver: 6.3.1 + + package-manager-detector@1.7.0: {} + + pathe@2.0.3: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} + picomatch@4.0.5: {} + + pnpm-workspace-yaml@1.6.1: + dependencies: + yaml: 2.9.0 + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -1228,8 +1908,28 @@ snapshots: preact@10.29.2: {} + prepend-http@2.0.0: {} + property-information@7.2.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pupa@2.1.1: + dependencies: + escape-goat: 2.1.1 + + quansync@1.0.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -1240,6 +1940,23 @@ snapshots: dependencies: regex-utilities: 2.3.0 + registry-auth-token@4.2.2: + dependencies: + rc: 1.2.8 + + registry-url@5.1.0: + dependencies: + rc: 1.2.8 + + responselike@1.0.2: + dependencies: + lowercase-keys: 1.0.1 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rfdc@1.4.1: {} rollup@4.62.2: @@ -1256,6 +1973,12 @@ snapshots: search-insights@2.17.3: {} + semver-diff@3.1.1: + dependencies: + semver: 6.3.1 + + semver@6.3.1: {} + semver@7.8.5: {} sharp@0.35.2: @@ -1288,25 +2011,96 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} speakingurl@14.0.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + superjson@2.2.6: dependencies: copy-anything: 4.0.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tabbable@6.5.0: {} + taze@19.14.1: + dependencies: + '@antfu/ni': 30.2.0 + '@henrygd/queue': 1.2.0 + cac: 7.0.0 + ofetch: 1.5.1 + package-manager-detector: 1.7.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.1 + restore-cursor: 5.1.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + unconfig: 7.5.0 + yaml: 2.9.0 + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyspawn@1.5.7: {} + + to-readable-stream@1.0.0: {} + trim-lines@3.0.1: {} + type-fest@0.20.2: {} + + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + + ufo@1.6.4: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.7 + jiti: 2.7.0 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -1330,6 +2124,34 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + untracked@1.6.4: + dependencies: + joycon: 3.1.1 + mri: 1.2.0 + tinyspawn: 1.5.7 + update-notifier: 5.1.0 + + update-notifier@5.1.0: + dependencies: + boxen: 5.1.2 + chalk: 4.1.2 + configstore: 5.0.1 + has-yarn: 2.1.0 + import-lazy: 2.1.0 + is-ci: 2.0.0 + is-installed-globally: 0.4.0 + is-npm: 5.0.0 + is-yarn-global: 0.3.0 + latest-version: 5.1.0 + pupa: 2.1.1 + semver: 7.8.5 + semver-diff: 3.1.1 + xdg-basedir: 4.0.0 + + url-parse-lax@3.0.0: + dependencies: + prepend-http: 2.0.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -1403,4 +2225,27 @@ snapshots: '@vue/server-renderer': 3.5.38(vue@3.5.38) '@vue/shared': 3.5.38 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + + xdg-basedir@4.0.0: {} + + yaml@2.9.0: {} + zwitch@2.0.4: {} diff --git a/docs/package.json b/docs/package.json index dd4bd5b68..009d05681 100644 --- a/docs/package.json +++ b/docs/package.json @@ -23,6 +23,8 @@ }, "devDependencies": { "sharp": "^0.35.0", + "taze": "catalog:", + "untracked": "catalog:", "vitepress": "^1.5.0" } } diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 000000000..4c6db5b61 --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,26 @@ +# Workspace config for the docs package — also the npm-side soak + catalog +# surface aube itself reads when installing docs deps (minimumReleaseAge in +# MINUTES; workspace yaml wins over .npmrc). Lives in docs/ on purpose: a +# repo-root pnpm-workspace.yaml would re-anchor the docs install (lockfile + +# virtual store) at the repo root. +# +# Managed by scripts/soak/soak.mts (`mise run soak` / `mise run soak:fix`). + +catalog: + taze: 19.14.1 + # no docs consumer yet — catalog-pinned for parity with nub, where + # untracked manages the .dockerignore build-context reduction + untracked: 1.6.4 + +# Cooldown (minutes) before newly published packages install — value managed +# by soak:fix; the window is SOAK_DAYS in scripts/soak/constants.mts. +minimumReleaseAge: 10080 + +# Exclusions: bare names / scope globs express standing trust; version pins +# are dated soak bypasses and REQUIRE, on the line above: +# # published: YYYY-MM-DD | removable: YYYY-MM-DD (removable = published + 7d) +# `mise run soak` rejects unannotated or expired pins (and flow-style [..] +# lists, which the gate can't validate); `soak:fix` prunes expired ones. +# minimumReleaseAgeExclude: +# # published: 2026-01-01 | removable: 2026-01-08 +# - 'example@1.2.3' diff --git a/docs/taze.config.mts b/docs/taze.config.mts new file mode 100644 index 000000000..f9ba42458 --- /dev/null +++ b/docs/taze.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from 'taze' + +// Cooldown derives from the canonical SOAK_DAYS so it can't drift from +// docs/pnpm-workspace.yaml minimumReleaseAge / docs/.npmrc min-release-age +// (scripts/soak/soak.mts asserts the data files match the same constant). +import { SOAK_DAYS } from '../scripts/soak/constants.mts' + +export default defineConfig({ + interactive: false, + loglevel: 'warn', + maturityPeriod: SOAK_DAYS, + mode: 'latest', + write: true, +}) diff --git a/external-tools.json b/external-tools.json new file mode 100644 index 000000000..5bcc98358 --- /dev/null +++ b/external-tools.json @@ -0,0 +1,174 @@ +{ + "tools": { + "pnpm": { + "description": "pnpm — the fleet's package manager.", + "version": "11.8.0", + "packageManager": "pnpm", + "repository": "github:pnpm/pnpm", + "release": "asset", + "notes": [ + "Latest soaked pnpm — CI downloads + SRI-verifies (sha512) the pinned asset", + "darwin-x64 has no SEA asset upstream; its pin is the npm registry tarball" + ], + "platforms": { + "darwin-arm64": { + "asset": "pnpm-darwin-arm64.tar.gz", + "integrity": "sha512-kgYcLu653+Gx7l7qEtM2zMNLBvb96ABcRSkzOrbfvkndb+veMt6lHJK5r3wTfuRYlDb8Lnk/h0wUD6sP3taJ9g==" + }, + "darwin-x64": { + "asset": "pnpm-11.8.0.tgz", + "integrity": "sha512-wfXnxMskHI8XS3Q4UdgvQrgCMkr8iw8Ra5atsVqgZmSUjd42lgo7oQebpbSyndAUATW5S1tfUmNZIknWjlVfJg==" + }, + "linux-arm64": { + "asset": "pnpm-linux-arm64.tar.gz", + "integrity": "sha512-p1IcVUlwYf3OJQYmdHGFr08d1BOSatHEmLJSBSdoNPGqOqfslFHjSqiuZ/3yuQxxypqp8OfnM3ci3Jh7ZEBlSw==" + }, + "linux-arm64-musl": { + "asset": "pnpm-linux-arm64-musl.tar.gz", + "integrity": "sha512-u5Do3diwK7FL5vk+i/x2I4q4ujdZ5gSoLNgmlt1w2C7NOjAZOpDK0CJYM4gDa5BetEwzdNpuN/gaMqzJk6I5NA==" + }, + "linux-x64": { + "asset": "pnpm-linux-x64.tar.gz", + "integrity": "sha512-Sn7hG4Xsq6pmi8TE8lpIkwRzAYzf5qtFk8zSsZWkSJFcmv5FlsrO8UP3lLRI+ppcRNYS10FMCTwXe3Nt66A0Pg==" + }, + "linux-x64-musl": { + "asset": "pnpm-linux-x64-musl.tar.gz", + "integrity": "sha512-5WXlo2yCDmoBIue5iHRK3zNSPVAmzci+5RhuxwUA4hnH9W3TFjDnQHyAzDVnkY878mfTXE35THZeEc1OKYPmag==" + }, + "win-arm64": { + "asset": "pnpm-win32-arm64.zip", + "integrity": "sha512-iv1hJEj9FUiVFae2cmCYjbRK0Xn27Uh+kpglT2LWvQyl4WeIOgs1ouKrptVxk3X3t7DB5vuYKbsmE+9BnoQ4FA==" + }, + "win-x64": { + "asset": "pnpm-win32-x64.zip", + "integrity": "sha512-jyqMgedndbck/xJjXPem5Lw7V0YtDfiUJIB81e/hmnrg3yLKPoyCKO5ILegYkLPIr945IJRcVoJZwjtog8FCqg==" + } + } + }, + "npm": { + "notes": [ + "npm 12 (min-release-age support). ONE platform-agnostic registry tarball, single integrity", + "Installed from the pinned tarball only — never npm install -g npm, no self-update path" + ], + "description": "npm — pinned, SRI-verified registry tarball; installed without self-update", + "repository": "npm:npm", + "version": "12.0.0", + "soakBypass": { + "version": "12.0.0", + "published": "2026-07-09", + "removable": "2026-07-16" + }, + "integrity": "sha512-qzvPQfNSY7louiM6rv7dL0hi5esBGLn1lLwxbdyL5XOIssWzoYMwn8xqvWhYcZL6onTkenYSxrtKxsFrFbUFyw==" + }, + "sfw-free": { + "description": "Socket Firewall (free tier) — malware gate on dep installs.", + "version": "1.13.1", + "repository": "github:SocketDev/sfw-free", + "release": "asset", + "binaryName": "sfw", + "notes": [ + "Used when SOCKET_SECURITY_KEY is not set", + "Shims npm/yarn/pnpm so every install call passes through the firewall" + ], + "platforms": { + "darwin-arm64": { + "asset": "sfw-free-macos-arm64", + "integrity": "sha512-T6wBOJGdRVSI8577lGqRNzNd6Q+1vqKyaqGgOA8G4M5MU2vcsUnXuJTgP2MMZjUqROSXUlFL0mHguuxXT2QadQ==" + }, + "darwin-x64": { + "asset": "sfw-free-macos-x86_64", + "integrity": "sha512-4G/AIY5UGU81wcepDKErY5u0nY85D8UM9nXTEPv8CR2rOV/s4IcmrkxywwZ3ipejHVQB7QmCVt0/SsqWglGikw==" + }, + "linux-arm64": { + "asset": "sfw-free-linux-arm64", + "integrity": "sha512-FYRYR52SL+KKFldW4ogYOUnTH5OSqvtXwzGFeWi0W2x+75KZcPiGzWBbhMmh0f5QtgYLV+4qdREgmKCBEayNtA==" + }, + "linux-x64": { + "asset": "sfw-free-linux-x86_64", + "integrity": "sha512-waLrsPG2a7EOv0XuvXDQZGgCZ4MTtOfZh8TmGbM6gn2B6Nh6HI+15jaoKdAS9wgdTyIqTuqU+O+NtVYd+kuFaA==" + }, + "win-x64": { + "asset": "sfw-free-windows-x86_64.exe", + "integrity": "sha512-YYnfwR6M/PHo72LSyKtpY3bAUG4F4ckToJqGx5Fkz4rwg1+48hkxuBaF3hdxHUdHPkfO5grDyoNgXGe7FojGcg==" + } + } + }, + "sfw-enterprise": { + "description": "Socket Firewall (enterprise tier) — selected when SOCKET_SECURITY_KEY is set.", + "version": "1.13.1", + "repository": "github:SocketDev/firewall-release", + "release": "asset", + "binaryName": "sfw", + "notes": [ + "Used when SOCKET_SECURITY_KEY is set (the one env var every Socket product reads)", + "Same shims as sfw-free, broader ecosystem support (Ruby, .NET, Go on Linux)" + ], + "platforms": { + "darwin-arm64": { + "asset": "sfw-macos-arm64", + "integrity": "sha512-ZDy2C6leKyTHZFvcZZpG2eQqVzs7buk+Hs92fkaMYME829QzyxdGQVVgwEVaGJpedGdUvhksKKcvT9IynI1kxg==" + }, + "darwin-x64": { + "asset": "sfw-macos-x86_64", + "integrity": "sha512-cm76we0sn7kqPOya/ZGQpPyhjRDyFT5lHigeT5Qso+QaPL6Cmwi0FVs2L7l63j+WR/9eYPU1WjjGOto5NbWsEQ==" + }, + "linux-arm64": { + "asset": "sfw-linux-arm64", + "integrity": "sha512-9qPi3mobBfyq1k+pD2GDG0tZkhy16f7FXE9oGiwmwPvy5PXwnlzqEXnYld3qGsKIVwNhunev/If26oROTHbrHA==" + }, + "linux-x64": { + "asset": "sfw-linux-x86_64", + "integrity": "sha512-lu9h8UzDZt34gdCEVHBGW6goE1Ayykq413EovV5B4nG7jBK27mI0GQstzVbWXA3wWaweT39PehXGtVpdqIDGSA==" + }, + "win-x64": { + "asset": "sfw-windows-x86_64.exe", + "integrity": "sha512-URZXauIsdUT12E2KTc4sfsxRmJm7nRJzAgM+IYGX4Xq+X0cl/eAbH5SpYIJKpsnW9csSztW9ceyPhlM+f3neIQ==" + } + } + }, + "zizmor": { + "description": "GitHub Actions security linter — audits .github/ for workflow-injection / credential-leak patterns.", + "version": "1.26.1", + "repository": "github:zizmorcore/zizmor", + "release": "asset", + "notes": [ + "Required: CI (blocks merges on medium+ findings)", + "Installed by the setup-and-install composite; SRI-verified (sha512) per platform" + ], + "platforms": { + "darwin-arm64": { + "asset": "zizmor-aarch64-apple-darwin.tar.gz", + "integrity": "sha512-UfLPPdYejR8fvrFwr9Tos7LKFvqr7YcAHWZlAmeo7imYI/fIucYnCY6HeDyk2cbFUvDpQTdps6WQS1QpqCtTfQ==" + }, + "darwin-x64": { + "asset": "zizmor-x86_64-apple-darwin.tar.gz", + "integrity": "sha512-SCbcEzF/zy2qNuNaocLPIUC4Wuq5GnHt0iUFve3qx6bJyFdzU6pDFZkF64dojGC7M5gAcK/4acXGPx3YnMDy/g==" + }, + "linux-arm64": { + "asset": "zizmor-aarch64-unknown-linux-gnu.tar.gz", + "integrity": "sha512-TkGvwt0zYdmiJ7LZmy6Bz9CdkqcuEpFKhXUK9m0MhSPxBx2gYBsrw6OxGSttMEQ3bvxdvyG6rqbnMgjyDZB1zA==" + }, + "linux-x64": { + "asset": "zizmor-x86_64-unknown-linux-gnu.tar.gz", + "integrity": "sha512-zTMERMDd3JfaRX12klj2fhZGDyrXeLkVUY1QJkCv8RRmAa9uVuH88gHOnv4CQtC5hXS7FPRJzvCVOnw38gV83g==" + }, + "win-x64": { + "asset": "zizmor-x86_64-pc-windows-msvc.zip", + "integrity": "sha512-Pijh/CrrOAkZzLiTr2LTHdI8d6+5Ql6B+suY6fXVmL8UVa+4Q36hHL5K67iRFz03v/V/UcrY6+dfhnmot/xfww==" + } + } + }, + "agentshield": { + "description": "Claude AI config security scanner (prompt injection, secrets)", + "purl": "pkg:npm/ecc-agentshield@1.4.0", + "integrity": "sha512-R98OO1Ujyk2lezDLb+iQmMhF6FwTJCHajy3G4FCB6x7wkSTqR9f8+eAelC5KDzYDsGSbc0sOZvjXOOPRBtMpDg==" + }, + "skillspector": { + "description": "NVIDIA's third-party-skill security scanner (LangGraph-based; YARA + AST + OSV.dev CVE lookups + optional LLM analysis). No PyPI release / no GH tags upstream — pinned to a git SHA on main + installed via a locked uv project (pyproject.toml + uv.lock, `uv sync --locked`; the fleet uv pin + exclude-newer make it reproducible). Sibling to AgentShield: AgentShield audits the operator's .claude/ config; SkillSpector audits untrusted upstream skills before install.", + "release": "uv-project", + "repository": "github:NVIDIA/skillspector", + "version": "2eb84478", + "versionDate": "2026-05-18" + } + } +} diff --git a/mise.toml b/mise.toml index 363207c5f..86b334cec 100644 --- a/mise.toml +++ b/mise.toml @@ -27,6 +27,30 @@ run = "cargo build" [tasks.test] run = "cargo test" +[tasks.soak] +run = "node scripts/soak/soak.mts --check" + +[tasks."test:scripts"] +run = "node --test scripts/soak/*.test.mts" + +[tasks."soak:fix"] +run = "node scripts/soak/soak.mts --fix" + +[tasks."deps:update"] +run = "node scripts/soak/update-deps.mts" + +[tasks."tools:check"] +run = "node scripts/soak/external-tools.mts --check" + +[tasks."remotes:check"] +run = "node scripts/soak/remotes.mts --check" + +[tasks."remotes:fix"] +run = "node scripts/soak/remotes.mts --fix" + +[tasks."tools:install"] +run = "node scripts/soak/external-tools.mts --install-all --shims" + [tasks."docs:dev"] depends = ["build"] dir = "docs" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..132fa3e04 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,16 @@ +# Pinned dated NIGHTLY, adopted under the soak window (SOAK_DAYS in +# scripts/soak/constants.mts): the pin is the newest nightly at least that +# old on the adoption date, and bumps follow the same rule. The `adopted:` +# line below is machine-read by `mise run soak` to enforce it. +# +# The pin is exact so the whole PGO/BOLT flow (mise run build:pgo, release +# upload-assets-pgo) instruments and optimizes with one toolchain and the +# collected profile matches the LLVM version that consumes it. +# llvm-tools-preview ships the llvm-profdata that benchmarks/pgo.bash +# otherwise requires by hand. rust-version in Cargo.toml stays the STABLE +# msrv floor (1.95, core::hint::cold_path). +# +# adopted: 2026-07-11 +[toolchain] +channel = "nightly-2026-07-04" +components = ["rustfmt", "clippy", "llvm-tools-preview"] diff --git a/scripts/soak/constants.mts b/scripts/soak/constants.mts new file mode 100644 index 000000000..3e5c1e46f --- /dev/null +++ b/scripts/soak/constants.mts @@ -0,0 +1,50 @@ +/** + * @file Canonical soak window — the ONE source for every release-age surface. + * A new or bumped third-party dependency must have been published at least + * this long before the repo adopts it: the cooldown catches a compromised + * upstream before it lands. Every soak surface DERIVES from `SOAK_DAYS` + * instead of hand-copying the number: + * + * - `.cargo/config.toml` -> `global-min-publish-age = " days"` (cargo -Zmin-publish-age) + * - `rust-toolchain.toml` -> dated nightly adopted only once >= SOAK_DAYS old + * - `docs/pnpm-workspace.yaml` -> `minimumReleaseAge: ` (aube reads minutes) + * - `docs/.npmrc` -> `min-release-age=` (npm >= 11.17, days) + * - `docs/taze.config.mts` -> `maturityPeriod: SOAK_DAYS` (imports this) + * + * The data files can't import this module, so `scripts/soak/soak.mts` + * asserts they match (code-is-law parity gate). + */ + +export const SOAK_DAYS = 7 + +// pnpm/aube `minimumReleaseAge` is expressed in MINUTES. +export const SOAK_MINUTES = SOAK_DAYS * 24 * 60 + +// Exclusion annotation carried on the line ABOVE every version-pinned +// `minimumReleaseAgeExclude` entry. `removable` = `published` + SOAK_DAYS; +// once `removable` is in the past the pin has soaked and must be pruned. +export const ANNOTATION_RE = + /^#\s*published:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*removable:\s*(\d{4}-\d{2}-\d{2})\s*$/ + +// A version-pinned exclude entry (`'name@1.2.3'` / `'@scope/name@1.2.3'`), +// as opposed to a bare name or `@scope/*` glob (which need no annotation: +// they express standing trust, not a dated soak bypass). +export const VERSION_PIN_RE = /^(@?[^@\s]+)@[^@\s]+$/ + +export function todayIso(): string { + return new Date().toISOString().slice(0, 10) +} + +// Shape-valid but impossible dates (2026-13-45) round-trip differently (or +// produce Invalid Date), so callers can reject them with a finding instead +// of crashing on Invalid Date arithmetic. +export function isValidIsoDate(iso: string): boolean { + const d = new Date(`${iso}T00:00:00Z`) + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso +} + +export function addDaysIso(iso: string, days: number): string { + const d = new Date(`${iso}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} diff --git a/scripts/soak/constants.test.mts b/scripts/soak/constants.test.mts new file mode 100644 index 000000000..3ee5b28d9 --- /dev/null +++ b/scripts/soak/constants.test.mts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + ANNOTATION_RE, + SOAK_DAYS, + SOAK_MINUTES, + VERSION_PIN_RE, + addDaysIso, + isValidIsoDate, + todayIso, +} from './constants.mts' + +test('SOAK_MINUTES derives from SOAK_DAYS', () => { + assert.equal(SOAK_MINUTES, SOAK_DAYS * 24 * 60) +}) + +test('todayIso returns a YYYY-MM-DD string', () => { + assert.match(todayIso(), /^\d{4}-\d{2}-\d{2}$/) +}) + +test('isValidIsoDate accepts real dates and rejects impossible ones', () => { + assert.equal(isValidIsoDate('2026-07-11'), true) + assert.equal(isValidIsoDate('2024-02-29'), true) // leap day + assert.equal(isValidIsoDate('2026-13-45'), false) // shape-valid, not real + assert.equal(isValidIsoDate('2026-02-30'), false) + assert.equal(isValidIsoDate('not-a-date'), false) +}) + +test('addDaysIso rolls over months and years', () => { + assert.equal(addDaysIso('2026-07-11', 7), '2026-07-18') + assert.equal(addDaysIso('2026-12-28', 7), '2027-01-04') + assert.equal(addDaysIso('2026-02-26', 7), '2026-03-05') +}) + +test('ANNOTATION_RE matches the published | removable comment shape', () => { + assert.ok(ANNOTATION_RE.test('# published: 2026-07-08 | removable: 2026-07-15')) + assert.ok(ANNOTATION_RE.test('#published: 2026-07-08|removable: 2026-07-15')) + assert.ok(!ANNOTATION_RE.test('# published 2026-07-08 removable 2026-07-15')) + assert.ok(!ANNOTATION_RE.test('- some-entry@1.2.3')) +}) + +test('VERSION_PIN_RE distinguishes dated pins from standing trust', () => { + assert.ok(VERSION_PIN_RE.test('left-pad@1.3.0')) + assert.ok(VERSION_PIN_RE.test('@scope/name@1.2.3')) + assert.ok(!VERSION_PIN_RE.test('react')) // bare name + assert.ok(!VERSION_PIN_RE.test('@myorg/*')) // scope glob +}) diff --git a/scripts/soak/dockerignore.test.mts b/scripts/soak/dockerignore.test.mts new file mode 100644 index 000000000..ac09a67dd --- /dev/null +++ b/scripts/soak/dockerignore.test.mts @@ -0,0 +1,51 @@ +// Tests for the docker-context reduction: the `untracked`-managed +// .dockerignore section. Skips wholesale in a repo with no repo-context +// docker builds (DOCKERIGNORE is null there). +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { test } from 'node:test' + +import { DOCKERIGNORE, REPO_ROOT } from './paths.mts' + +const skip = DOCKERIGNORE ? false : 'repo has no repo-context docker builds' + +const START = '### start auto generated using `untracked`' +const END = '### finished auto generated using `untracked`' + +const body = DOCKERIGNORE ? readFileSync(path.join(REPO_ROOT, DOCKERIGNORE), 'utf8') : '' + +test('managed .dockerignore carries the untracked markers with content between', { skip }, () => { + const start = body.indexOf(START) + const end = body.indexOf(END) + assert.ok(start !== -1 && end !== -1, 'both markers present') + assert.ok(end - start > 100, 'generated section is not empty') +}) + +test('hand-written custom rules survive above the managed section', { skip }, () => { + const custom = body.slice(0, body.indexOf(START)) + assert.match(custom, /^target\//m, 'build-artifact rule preserved') + assert.match(custom, /^node_modules\//m, 'node_modules rule preserved') +}) + +test('the package.json whitelist is honored as trailing negations', { skip }, () => { + const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const whitelist: string[] = pkg.untracked?.whitelist ?? [] + assert.ok(whitelist.length > 0, 'whitelist declares the load-bearing paths') + for (const entry of whitelist) { + // Docker applies patterns anchored to the context root, so only + // root-level entries need an explicit negation to survive the `.*` + // dotfile rule; nested ones are untouched by it but must exist. + if (!entry.includes('/')) { + assert.ok(body.includes(`!${entry}`), `negation for whitelisted '${entry}'`) + } + assert.ok(existsSync(path.join(REPO_ROOT, entry)), `whitelisted path '${entry}' exists`) + } +}) + +test('the load-bearing nested .cargo config is not root-anchored away', { skip }, () => { + // The in-container builders depend on crates/nub-native/.cargo/config.toml; + // the generated `.*` rule is context-root-anchored so it must NOT appear in + // a form that matches nested dotdirs. + assert.ok(!body.includes('**/.*'), 'no recursive dotfile exclusion') +}) diff --git a/scripts/soak/external-tools.mts b/scripts/soak/external-tools.mts new file mode 100644 index 000000000..898275457 --- /dev/null +++ b/scripts/soak/external-tools.mts @@ -0,0 +1,560 @@ +#!/usr/bin/env node +/** + * @file Pinned external security tooling — download, verify, shim. + * `external-tools.json` (repo root) pins every tool to an exact version + * with a sha512 SRI integrity per platform asset. This script is the only + * way those tools reach a machine: nothing here trusts "latest". + * + * - `--check` validate every pin (shape, SRI prefix, soak + * annotations on any soakBypass) — CI gate, no network + * - `--install ` download + SRI-verify + install into the local + * tool rack (see paths.mts RACK_DIR) with a PATH + * handle in BIN_DIR + * - `--install-all` every installable pin + * - `--shims` write sfw shims (npm/yarn/pnpm/pip/pip3/uv/cargo) + * into BIN_DIR so installs route through the firewall + * - `--print-bin` print BIN_DIR (for `>> $GITHUB_PATH` in CI) + * + * `sfw` resolves to sfw-enterprise when SOCKET_SECURITY_KEY is set (the + * one env var every Socket product reads), else sfw-free — free tier + * needs no key, so CI is firewalled from day one and upgrades itself + * when the repo secret lands. + */ + +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { SOAK_DAYS, addDaysIso, isValidIsoDate, todayIso } from './constants.mts' +import { + BIN_DIR, + DOCKER_PREBAKE, + EXTERNAL_TOOLS_JSON, + PM_DEP_INSTALLERS, + RACK_DIR, + REPO_ROOT, + SURFACES, +} from './paths.mts' + +const SFW_ECOSYSTEMS = ['npm', 'yarn', 'pnpm', 'pip', 'pip3', 'uv', 'cargo'] + +interface PlatformPin { + asset: string + integrity: string +} + +interface ToolPin { + description?: string + version?: string + repository?: string + release?: string + binaryName?: string + purl?: string + integrity?: string + platforms?: Record + soakBypass?: { version: string; published: string; removable: string } +} + +function loadTools(): Record { + return JSON.parse(readFileSync(EXTERNAL_TOOLS_JSON, 'utf8')).tools +} + +function platformKey(): string { + const osKey = { darwin: 'darwin', linux: 'linux', win32: 'win' }[process.platform] + const archKey = { arm64: 'arm64', x64: 'x64' }[process.arch] + if (!osKey || !archKey) { + throw new Error(`unsupported platform ${process.platform}-${process.arch}`) + } + return `${osKey}-${archKey}` +} + +function sriSha512(buf: Buffer): string { + return `sha512-${createHash('sha512').update(buf).digest('base64')}` +} + +export function checkPins(tools: Record): string[] { + const out: string[] = [] + for (const [name, pin] of Object.entries(tools)) { + if (!pin.version && !pin.purl) { + out.push(`${name}: no version or purl pin`) + } + const integrities = [ + ...(pin.integrity ? [pin.integrity] : []), + ...Object.values(pin.platforms ?? {}).map(p => p.integrity), + ] + if (pin.release === 'asset' && integrities.length === 0) { + out.push(`${name}: release asset without any integrity pin`) + } + for (const sri of integrities) { + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(sri)) { + out.push(`${name}: integrity is not a sha512 SRI: ${sri}`) + } + } + if (pin.soakBypass) { + const { published, removable } = pin.soakBypass + if (!isValidIsoDate(published) || !isValidIsoDate(removable)) { + out.push(`${name}: soakBypass dates are not real YYYY-MM-DD calendar dates`) + continue + } + const expected = addDaysIso(published, SOAK_DAYS) + if (removable !== expected) { + out.push(`${name}: soakBypass removable ${removable}, wanted ${expected} (published + ${SOAK_DAYS}d)`) + } + // A bypass whose window has passed is dead weight: the version has + // soaked, so the annotation must come off (same rule the workspace + // yaml excludes live under). + if (removable < todayIso()) { + out.push(`${name}: soakBypass expired (removable ${removable}) — the pin has soaked, remove the annotation`) + } + } + } + return out +} + +function sriToHex(sri: string): string { + return Buffer.from(sri.slice('sha512-'.length), 'base64').toString('hex') +} + +/** + * Parity gate for the CI agent image: its build context can't reach the + * tracked pin sources, so the Dockerfile embeds copies of the sfw pin + * (version + per-arch sha512 hex) and the toolchain channels. Assert the + * copies match external-tools.json / rust-toolchain.toml so a pin bump + * can't silently strand the image on old bits. + */ +export function checkDockerPrebake( + dockerBody: string, + tools: Record, + toolchainToml: string, + rustVersion = '', +): string[] { + const out: string[] = [] + const shimList = /for cmd in ([^;]+);/.exec(dockerBody)?.[1]?.trim().split(/\s+/) + if (shimList && shimList.join(' ') !== SFW_ECOSYSTEMS.join(' ')) { + out.push(`docker prebake: shim list [${shimList.join(' ')}] != SFW_ECOSYSTEMS [${SFW_ECOSYSTEMS.join(' ')}]`) + } + if (rustVersion && !dockerBody.includes(`toolchain install ${rustVersion}`)) { + out.push(`docker prebake: image does not pre-install the ${rustVersion} msrv toolchain`) + } + const sfw = tools['sfw-free'] + const version = /rack\/sfw-free\/([^/\s]+)\//.exec(dockerBody)?.[1] + if (version !== sfw?.version) { + out.push( + `docker prebake: sfw-free version ${version ?? '(missing)'} != external-tools.json ${sfw?.version}`, + ) + } + const urlVersion = /sfw-free\/releases\/download\/v([^/\s]+)\//.exec(dockerBody)?.[1] + if (urlVersion !== sfw?.version) { + out.push( + `docker prebake: sfw-free download url v${urlVersion ?? '(missing)'} != external-tools.json ${sfw?.version}`, + ) + } + const pairs = [...dockerBody.matchAll(/asset=(\S+);\s*sha=([0-9a-f]{128})/g)] + if (pairs.length === 0) { + out.push('docker prebake: no asset/sha pin pairs found in the Dockerfile') + } + for (const [, asset, hex] of pairs) { + const plat = Object.values(sfw?.platforms ?? {}).find(p => p.asset === asset) + if (!plat) { + out.push(`docker prebake: asset ${asset} has no pin in external-tools.json`) + continue + } + if (sriToHex(plat.integrity) !== hex) { + out.push(`docker prebake: sha for ${asset} != hex of external-tools.json SRI`) + } + } + const channel = /^channel\s*=\s*"([^"]+)"/m.exec(toolchainToml)?.[1] + if (channel && !dockerBody.includes(`toolchain install ${channel} `)) { + out.push(`docker prebake: image does not pre-install the pinned toolchain ${channel}`) + } + return out +} + +export async function download(url: string, expectedSri: string): Promise { + const headers: Record = {} + // Only GitHub gets the token (private release assets); sending it to any + // other host (e.g. the npm registry for purl tools) would leak the + // credential. Cross-origin redirects strip the header automatically. + if (process.env.GITHUB_TOKEN && new URL(url).hostname === 'github.com') { + headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}` + } + // Fail fast on a stalled release/registry response instead of hanging + // CI; 120s is generous for the largest pinned binary on a slow runner. + const res = await fetch(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(120_000), + }) + if (!res.ok) { + throw new Error(`download failed ${res.status} ${url}`) + } + const buf = Buffer.from(await res.arrayBuffer()) + const actual = sriSha512(buf) + if (actual !== expectedSri) { + throw new Error(`integrity mismatch for ${url}\n expected ${expectedSri}\n actual ${actual}`) + } + return buf +} + +export function extractArchive(name: string, destDir: string, asset: string, buf: Buffer): void { + const archive = path.join(destDir, asset) + writeFileSync(archive, buf) + // bsdtar extracts zip via plain -xf too (macOS runners ship it as `tar`). + // Windows needs BOTH quirks handled: Git Bash's PATH shadows System32's + // bsdtar with GNU tar (which can't read zip — "does not look like a tar + // archive"), so address the System32 binary explicitly; and tar parses an + // absolute `C:\...` archive path as a remote `host:path` ("Cannot connect + // to C"), so the archive is addressed RELATIVE to a cwd. + const tarBin = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' + const flags = asset.endsWith('.zip') ? '-xf' : '-xzf' + const res = spawnSync(tarBin, [flags, asset], { cwd: destDir, stdio: 'inherit' }) + rmSync(archive) + if (res.status !== 0) { + throw new Error(`${name}: archive extract failed`) + } +} + +export function linkHandle(target: string, name: string): void { + mkdirSync(BIN_DIR, { recursive: true }) + const handle = path.join(BIN_DIR, name) + // force also removes a DANGLING handle (existsSync would report false + // for one and a bare symlink would then throw EEXIST). + rmSync(handle, { force: true }) + if (process.platform === 'win32') { + // A symlinked/copied handle breaks Windows SEA binaries: pnpm.exe + // resolves its dist/ siblings from the handle's OWN directory, not the + // rack. Forward to the absolute rack target instead — a .cmd for + // cmd/pwsh and an extensionless bash shim for Git Bash. Non-.exe + // targets are node entry scripts (registry-tarball tools). The handle + // BASE must not keep a caller-supplied .exe suffix: pwsh resolves + // `pnpm` to `pnpm.exe` first, and a bash text file wearing that name + // is "not a valid application for this OS platform". + const base = path.join(BIN_DIR, name.replace(/\.exe$/, '')) + const viaExe = target.endsWith('.exe') + rmSync(base, { force: true }) + rmSync(`${base}.cmd`, { force: true }) + rmSync(`${base}.exe`, { force: true }) + writeFileSync( + `${base}.cmd`, + viaExe ? `@echo off\r\n"${target}" %*\r\n` : `@echo off\r\nnode "${target}" %*\r\n`, + ) + writeFileSync( + base, + viaExe + ? `#!/usr/bin/env bash\nexec "${target}" "$@"\n` + : `#!/usr/bin/env bash\nexec node "${target}" "$@"\n`, + ) + chmodSync(base, 0o755) + return + } + symlinkSync(target, handle) +} + +async function installAssetTool(name: string, pin: ToolPin): Promise { + const plat = pin.platforms?.[platformKey()] + if (!plat) { + throw new Error(`${name}: no pinned asset for ${platformKey()}`) + } + // A platform pinned to a registry .tgz (pnpm has no darwin-x64 SEA + // upstream) routes through the npm-tarball path instead. + if (plat.asset.endsWith('.tgz')) { + await installNpmTarball(name, name, pin.version!, plat.integrity) + return + } + const repo = pin.repository!.replace(/^github:/, '') + const url = `https://github.com/${repo}/releases/download/v${pin.version}/${plat.asset}` + let binName = pin.binaryName ?? name + const destDir = path.join(RACK_DIR, name, pin.version!) + let destBin = path.join(destDir, binName) + if (existsSync(destBin)) { + linkHandle(destBin, binName) + console.log(`[external-tools] ${name}@${pin.version} already installed`) + return + } + console.log(`[external-tools] downloading ${name}@${pin.version} (${plat.asset})`) + const buf = await download(url, plat.integrity) + mkdirSync(destDir, { recursive: true }) + if (plat.asset.endsWith('.tar.gz') || plat.asset.endsWith('.zip')) { + extractArchive(name, destDir, plat.asset, buf) + // Windows archives ship `.exe`; resolve it before giving up, and + // clean the partial dir so a retry re-extracts instead of wedging. + if (!existsSync(destBin) && existsSync(`${destBin}.exe`)) { + destBin = `${destBin}.exe` + binName = `${binName}.exe` + } + if (!existsSync(destBin)) { + rmSync(destDir, { recursive: true, force: true }) + throw new Error(`${name}: ${binName} not found in extracted archive`) + } + } else { + writeFileSync(destBin, buf) + } + chmodSync(destBin, 0o755) + linkHandle(destBin, binName) + console.log(`[external-tools] installed ${name}@${pin.version} -> ${destBin}`) +} + +/** + * Registry-tarball install: verify against the pinned SRI, extract into the + * rack, materialize runtime deps with the repo's own package manager (this + * repo IS one — dogfood it, with pnpm/npm as last-resort fallbacks), and + * link a node wrapper for the package's bin. Tarballs that bundle their + * node_modules (npm does) skip the dependency install. + */ +async function installNpmTarball( + name: string, + pkg: string, + version: string, + integrity: string, +): Promise { + const base = pkg.split('/').pop() + const url = `https://registry.npmjs.org/${pkg}/-/${base}-${version}.tgz` + const destDir = path.join(RACK_DIR, name, version) + const pkgDir = path.join(destDir, 'package') + // Completion marker is the extracted manifest, not the directory: a + // failed/interrupted extract leaves the dir behind and would otherwise + // wedge every later install. Reset and redo instead. + const manifest = path.join(pkgDir, 'package.json') + if (!existsSync(manifest)) { + rmSync(destDir, { recursive: true, force: true }) + console.log(`[external-tools] downloading ${name}@${version} (npm registry)`) + const buf = await download(url, integrity) + mkdirSync(destDir, { recursive: true }) + extractArchive(name, destDir, 'package.tgz', buf) + } + const pkgJson = JSON.parse(readFileSync(manifest, 'utf8')) + const hasDeps = Object.keys(pkgJson.dependencies ?? {}).length > 0 + if (hasDeps && !existsSync(path.join(pkgDir, 'node_modules'))) { + if (installDeps(name, pkgDir) !== 0) { + rmSync(destDir, { recursive: true, force: true }) + throw new Error(`${name}: dependency install failed`) + } + } + const bins = + typeof pkgJson.bin === 'string' ? { [name]: pkgJson.bin } : (pkgJson.bin ?? {}) + const binRel = bins[name] ?? Object.values(bins)[0] + if (binRel) { + const binAbs = path.join(pkgDir, binRel as string) + if (process.platform === 'win32') { + // linkHandle writes node-invoking .cmd + bash forwarders for a + // non-.exe target — no bash-only wrapper to strand under pwsh. + linkHandle(binAbs, name) + } else { + const wrapper = path.join(RACK_DIR, name, `${name}-wrapper`) + writeFileSync(wrapper, `#!/usr/bin/env bash\nexec node '${binAbs}' "$@"\n`) + chmodSync(wrapper, 0o755) + linkHandle(wrapper, name) + } + } + console.log(`[external-tools] installed ${name}@${version}`) +} + +function installDeps(name: string, pkgDir: string): number { + for (const [cmd, ...args] of PM_DEP_INSTALLERS) { + if (cmd!.includes('/') && !existsSync(cmd!)) { + continue + } + console.log(`[external-tools] ${name}: installing deps via ${path.basename(cmd!)}`) + const res = spawnSync(cmd!, args, { cwd: pkgDir, stdio: 'inherit' }) + if (res.error) { + continue + } + return res.status ?? 1 + } + console.error(`[external-tools] ${name}: no package manager available for deps`) + return 1 +} + +export async function installTool(name: string, tools: Record): Promise { + // `sfw` is a flavor pair: the enterprise binary when a Socket token is + // present (repo secret), the keyless free tier otherwise. The firewall + // shim mechanism is POSIX (bash shims, extension-less symlink handles) — + // skip cleanly on Windows rather than install something unusable. + if (name === 'sfw') { + if (process.platform === 'win32') { + console.log('[external-tools] sfw shims are POSIX-only — skipping on windows') + return + } + name = process.env.SOCKET_SECURITY_KEY ? 'sfw-enterprise' : 'sfw-free' + } + const pin = tools[name] + if (!pin) { + throw new Error(`unknown tool ${name} (see external-tools.json)`) + } + if (pin.release === 'asset') { + await installAssetTool(name, pin) + return + } + if (pin.purl) { + // npm-packaged scanner (agentshield): verify the registry tarball + // against the pinned SRI, then run via the extracted package. + const m = /^pkg:npm\/(.+)@([^@]+)$/.exec(pin.purl) + if (!m) { + throw new Error(`${name}: unsupported purl ${pin.purl}`) + } + await installNpmTarball(name, m[1]!, m[2]!, pin.integrity!) + return + } + if (pin.repository?.startsWith('npm:')) { + // Platform-agnostic registry tarball (npm itself ships this way): one + // tarball, one integrity, pure JS run through node. Never installed + // via `npm install -g npm` — no self-update path. + await installNpmTarball(name, pin.repository.slice('npm:'.length), pin.version!, pin.integrity!) + return + } + if (pin.release === 'uv-project') { + // Git-SHA-pinned python project; not auto-installed (needs uv). + const repo = pin.repository!.replace(/^github:/, '') + console.log( + `[external-tools] ${name} is a uv project — run: uvx --from git+https://github.com/${repo}@${pin.version} ${name}`, + ) + return + } + throw new Error(`${name}: no installable shape (release=${pin.release ?? 'none'})`) +} + +/** + * The rack location of a pinned command's runnable entry, or '' when the + * command isn't rack-pinned/installed. Resolved from the manifest + rack + * contents (never from the bin handle: after a --shims run the handle is + * the shim itself, and resolving through it wouldn't survive a re-run). + */ +export function rackRealFor(cmd: string, tools: Record): string { + const pin = tools[cmd] + if (!pin) { + return '' + } + const candidates = [ + path.join(RACK_DIR, cmd, `${cmd}-wrapper`), + ...(pin.version ? [path.join(RACK_DIR, cmd, pin.version, pin.binaryName ?? cmd)] : []), + ] + return candidates.find(c => existsSync(c)) ?? '' +} + +/** + * sfw shims: tiny wrappers named after each package manager that route the + * real invocation through the firewall. A sentinel env var breaks the + * recursion when sfw itself re-invokes the tool; the real binary is found + * by stripping the rack's bin dir out of PATH. + */ +export function writeShims(tools: Record): void { + if (process.platform === 'win32') { + console.log('[external-tools] sfw shims are POSIX-only — skipping on windows') + return + } + mkdirSync(BIN_DIR, { recursive: true }) + for (const cmd of SFW_ECOSYSTEMS) { + const sentinel = `SFW_SHIM_ACTIVE_${cmd.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}` + // When the command itself is rack-pinned (pnpm/npm), the shim wraps the + // PINNED binary; unpinned commands resolve at run time by stripping the + // shim dir out of PATH. + const handle = path.join(BIN_DIR, cmd) + const rackReal = rackRealFor(cmd, tools) + const resolveReal = rackReal + ? `REAL='${rackReal}'` + : `CLEAN_PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vFx '${BIN_DIR}' | paste -sd ':' -) +REAL=$(PATH="$CLEAN_PATH" command -v '${cmd}' || true)` + const body = `#!/usr/bin/env bash +# sfw shim for ${cmd} — managed by scripts/soak/external-tools.mts --shims +set -euo pipefail +${resolveReal} +if [ -n "\${${sentinel}:-}" ] || [ -z "$REAL" ] || ! command -v sfw >/dev/null 2>&1; then + [ -n "$REAL" ] && exec "$REAL" "$@" + echo "${cmd}: not found" >&2; exit 127 +fi +export ${sentinel}=1 +exec sfw '${cmd}' "$@" +` + // Remove the handle before writing: writeFileSync FOLLOWS a symlink, so + // writing through a rack handle would overwrite the pinned binary itself + // with the shim body (which then execs itself forever). + rmSync(handle, { force: true }) + writeFileSync(handle, body) + chmodSync(handle, 0o755) + } + console.log(`[external-tools] wrote sfw shims for ${SFW_ECOSYSTEMS.join(', ')} in ${BIN_DIR}`) + console.log(`[external-tools] prepend ${BIN_DIR} to PATH to activate`) +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + if (argv.includes('--print-bin')) { + console.log(BIN_DIR) + return 0 + } + const tools = loadTools() + if (argv.includes('--check') || argv.length === 0) { + const problems = checkPins(tools) + if (DOCKER_PREBAKE) { + const dockerAbs = path.join(REPO_ROOT, DOCKER_PREBAKE) + const toolchainAbs = path.join(REPO_ROOT, SURFACES.toolchainToml) + if (existsSync(dockerAbs)) { + problems.push( + ...checkDockerPrebake( + readFileSync(dockerAbs, 'utf8'), + tools, + existsSync(toolchainAbs) ? readFileSync(toolchainAbs, 'utf8') : '', + /^rust-version\s*=\s*"([^"]+)"/m.exec( + readFileSync(path.join(REPO_ROOT, 'Cargo.toml'), 'utf8'), + )?.[1] ?? '', + ), + ) + } + } + for (const p of problems) { + console.error(`[external-tools] ${p}`) + } + if (problems.length === 0) { + console.log(`[external-tools] ${Object.keys(tools).length} pins valid`) + } + return problems.length === 0 ? 0 : 1 + } + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--install') { + await installTool(argv[++i]!, tools) + } + } + if (argv.includes('--install-all')) { + for (const name of Object.keys(tools)) { + if (name === 'sfw-enterprise' || name === 'sfw-free') { + continue + } + await installTool(name, tools) + } + await installTool('sfw', tools) + } + if (argv.includes('--shims')) { + writeShims(tools) + } + return 0 +} + +// realpath + pathToFileURL so symlinked checkouts and paths needing URL +// encoding still register as the entrypoint (ESM realpaths import.meta.url). +const isMain = + process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url +if (isMain) { + main().then( + code => { + process.exitCode = code + }, + err => { + console.error(`[external-tools] ${err.message}`) + process.exitCode = 1 + }, + ) +} diff --git a/scripts/soak/external-tools.test.mts b/scripts/soak/external-tools.test.mts new file mode 100644 index 000000000..c24426ec7 --- /dev/null +++ b/scripts/soak/external-tools.test.mts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { SOAK_DAYS, addDaysIso, todayIso } from './constants.mts' +import { + checkDockerPrebake, + checkPins, + download, + extractArchive, + installTool, + main, +} from './external-tools.mts' +import { DOCKER_PREBAKE, EXTERNAL_TOOLS_JSON, REPO_ROOT, SURFACES } from './paths.mts' + +const GOOD_SRI = + 'sha512-waLrsPG2a7EOv0XuvXDQZGgCZ4MTtOfZh8TmGbM6gn2B6Nh6HI+15jaoKdAS9wgdTyIqTuqU+O+NtVYd+kuFaA==' + +test('the repo external-tools.json passes checkPins', () => { + const tools = JSON.parse(readFileSync(EXTERNAL_TOOLS_JSON, 'utf8')).tools + assert.deepEqual(checkPins(tools), []) +}) + +test('checkPins flags missing pins, bad SRIs, and asset entries with no integrity', () => { + assert.equal(checkPins({ a: {} }).length, 1) + assert.equal(checkPins({ a: { version: '1.0.0', integrity: 'sha256-abc' } }).length, 1) + assert.equal(checkPins({ a: { version: '1.0.0', release: 'asset' } }).length, 1) +}) + +test('checkPins validates soakBypass dates, arithmetic, and expiry', () => { + const pub = addDaysIso(todayIso(), -1) + const good = { + a: { + version: '1.0.0', + integrity: GOOD_SRI, + soakBypass: { version: '1.0.0', published: pub, removable: addDaysIso(pub, SOAK_DAYS) }, + }, + } + assert.deepEqual(checkPins(good), []) + const wrongMath = structuredClone(good) + wrongMath.a.soakBypass.removable = addDaysIso(pub, 3) + assert.match(checkPins(wrongMath)[0]!, /removable/) + const expired = structuredClone(good) + expired.a.soakBypass = { version: '1.0.0', published: '2020-01-01', removable: '2020-01-08' } + assert.match(checkPins(expired)[0]!, /expired/) + const impossible = structuredClone(good) + impossible.a.soakBypass = { version: '1.0.0', published: '2026-13-45', removable: '2026-13-52' } + assert.match(checkPins(impossible)[0]!, /calendar/) +}) + +test('the repo Dockerfile prebake (when present) matches the tracked pins', t => { + if (!DOCKER_PREBAKE || !existsSync(path.join(REPO_ROOT, DOCKER_PREBAKE))) { + t.skip('repo has no prebake image') + return + } + const tools = JSON.parse(readFileSync(EXTERNAL_TOOLS_JSON, 'utf8')).tools + const docker = readFileSync(path.join(REPO_ROOT, DOCKER_PREBAKE), 'utf8') + const toolchain = readFileSync(path.join(REPO_ROOT, SURFACES.toolchainToml), 'utf8') + assert.deepEqual(checkDockerPrebake(docker, tools, toolchain), []) + // and drift in any direction is caught + assert.ok(checkDockerPrebake(docker.replace(/sha=[0-9a-f]{8}/, 'sha=deadbeef'), tools, toolchain).length > 0) + assert.ok( + checkDockerPrebake(docker, tools, toolchain.replace(/channel = ".*"/, 'channel = "nightly-1999-01-01"')).length > + 0, + ) +}) + +const sriOf = (buf: Buffer) => `sha512-${createHash('sha512').update(buf).digest('base64')}` + +function withEnv(name: string, value: string | undefined, fn: () => Promise): Promise { + const saved = process.env[name] + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + return fn().finally(() => { + if (saved === undefined) { + delete process.env[name] + } else { + process.env[name] = saved + } + }) +} + +// Hermetic fixture: one line per drift class, so every checkDockerPrebake +// failure branch is exercised even in a repo with no prebake image. +test('checkDockerPrebake flags every drift class (synthetic image)', () => { + const tools = { + 'sfw-free': { + version: '1.0.0', + platforms: { 'linux-arm64': { asset: 'sfw-linux-arm64', integrity: GOOD_SRI } }, + }, + } + const wrongHex = 'ab'.repeat(64) + const body = [ + 'for cmd in npm yarn; do make_shim "$cmd"; done', + 'RUN curl -o /x https://github.com/SocketDev/sfw-free/releases/download/v0.9.9/sfw-linux-arm64', + 'COPY rack/sfw-free/0.9.9/sfw /usr/local/bin/sfw', + `RUN asset=ghost-asset; sha=${wrongHex} verify`, + `RUN asset=sfw-linux-arm64; sha=${wrongHex} verify`, + ].join('\n') + const problems = checkDockerPrebake(body, tools, 'channel = "nightly-2026-07-04"\n', '9.9.9') + for (const needle of [ + /shim list/, + /msrv toolchain/, + /version 0\.9\.9/, + /download url v0\.9\.9/, + /ghost-asset has no pin/, + /sha for sfw-linux-arm64/, + /pinned toolchain nightly-2026-07-04/, + ]) { + assert.ok(problems.some(p => needle.test(p)), `expected a finding matching ${needle}`) + } + assert.ok( + checkDockerPrebake('nothing pinned here', tools, '').some(p => + /no asset\/sha pin pairs/.test(p), + ), + ) +}) + +test('download sends the GitHub token to github.com only', async t => { + const payload = Buffer.from('pinned-bytes') + const seen: Array<{ host: string; auth: string | undefined }> = [] + t.mock.method(globalThis, 'fetch', async (url: string | URL, init?: RequestInit) => { + seen.push({ + host: new URL(String(url)).hostname, + auth: (init?.headers as Record | undefined)?.authorization, + }) + return new Response(payload) + }) + await withEnv('GITHUB_TOKEN', 'ghs_test_token', async () => { + await download('https://github.com/o/r/releases/download/v1/a.tgz', sriOf(payload)) + await download('https://registry.npmjs.org/x/-/x-1.0.0.tgz', sriOf(payload)) + }) + assert.equal(seen[0]!.auth, 'Bearer ghs_test_token') + assert.equal(seen[1]!.auth, undefined) +}) + +test('download rejects http errors and integrity mismatches', async t => { + const payload = Buffer.from('served-bytes') + let status = 503 + t.mock.method(globalThis, 'fetch', async () => new Response(payload, { status })) + await assert.rejects(download('https://example.com/a', sriOf(payload)), /download failed 503/) + status = 200 + await assert.rejects(download('https://example.com/a', 'sha512-AAAA'), /integrity mismatch/) + assert.deepEqual(await download('https://example.com/a', sriOf(payload)), payload) +}) + +test('extractArchive unpacks a tar.gz, removes the archive, throws on junk', t => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'soak-extract-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + writeFileSync(path.join(dir, 'hello.txt'), 'hi\n') + spawnSync('tar', ['-czf', path.join(dir, 'a.tar.gz'), '-C', dir, 'hello.txt']) + const buf = readFileSync(path.join(dir, 'a.tar.gz')) + const dest = mkdtempSync(path.join(os.tmpdir(), 'soak-extract-dest-')) + t.after(() => rmSync(dest, { recursive: true, force: true })) + extractArchive('t', dest, 'a.tar.gz', buf) + assert.ok(existsSync(path.join(dest, 'hello.txt'))) + assert.ok(!existsSync(path.join(dest, 'a.tar.gz'))) + assert.throws(() => extractArchive('t', dest, 'bad.tgz', Buffer.from('junk')), /extract failed/) +}) + +test('installTool rejects unknown tools, foreign purls, and shapeless pins', async () => { + await assert.rejects(installTool('ghost', {}), /unknown tool/) + await assert.rejects( + installTool('x', { x: { purl: 'pkg:pypi/foo@1.0.0', integrity: GOOD_SRI } }), + /unsupported purl/, + ) + await assert.rejects(installTool('y', { y: { version: '1.0.0' } }), /no installable shape/) + await assert.rejects( + installTool('z', { z: { release: 'asset', version: '1.0.0', platforms: {} } }), + /no pinned asset for/, + ) +}) + +test('installTool resolves the sfw flavor from SOCKET_SECURITY_KEY', async t => { + if (process.platform === 'win32') { + t.skip('sfw shims are POSIX-only') + return + } + // An empty tools record makes the resolved flavor observable in the + // rejection message — no download is ever attempted. + await withEnv('SOCKET_SECURITY_KEY', undefined, () => + assert.rejects(installTool('sfw', {}), /unknown tool sfw-free/), + ) + await withEnv('SOCKET_SECURITY_KEY', 'sk_test', () => + assert.rejects(installTool('sfw', {}), /unknown tool sfw-enterprise/), + ) +}) + +test('installTool prints the uvx line for uv-project pins without installing', async () => { + const tools = JSON.parse(readFileSync(EXTERNAL_TOOLS_JSON, 'utf8')).tools + const uv = Object.keys(tools).find(name => tools[name].release === 'uv-project') + assert.ok(uv, 'the manifest is expected to pin at least one uv project') + await installTool(uv!, tools) +}) + +// Glue: main's read-only CLI paths against the tracked manifest — the same +// gate CI runs, exercised in-process so main() itself stays covered. +test('main --print-bin and --check are read-only and exit 0', async () => { + assert.equal(await main(['--print-bin']), 0) + assert.equal(await main(['--check']), 0) +}) + +// End to end through the entrypoint guard: the CLI must resolve as main +// (realpath + file URL) and exit 0 on the tracked manifest. +test('CLI: node external-tools.mts --check exits 0', () => { + const script = fileURLToPath(new URL('./external-tools.mts', import.meta.url)) + const res = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' }) + assert.equal(res.status, 0, res.stderr) +}) diff --git a/scripts/soak/paths.mts b/scripts/soak/paths.mts new file mode 100644 index 000000000..823737aa5 --- /dev/null +++ b/scripts/soak/paths.mts @@ -0,0 +1,71 @@ +/** + * @file 1 path, 1 reference — every filesystem location the soak + + * external-tools scripts touch is declared here exactly once. Scripts + * import from this module instead of re-deriving paths, so a surface can + * move (or differ between repos carrying these scripts) with a one-line + * change. + */ + +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +// Soak surfaces (repo-relative). aube keeps its npm surfaces in docs/ — +// that's where the package.json lives and where aube itself reads workspace +// yaml + npmrc; a ROOT pnpm-workspace.yaml would re-anchor the docs install +// at the repo root. +export const SURFACES = { + cargoConfig: '.cargo/config.toml', + npmrc: 'docs/.npmrc', + workspaceYaml: 'docs/pnpm-workspace.yaml', + tazeConfig: 'docs/taze.config.mts', + toolchainToml: 'rust-toolchain.toml', + renovateJson: '.github/renovate.json', +} + +// The directory holding the npm package the soak governs (taze runs here, +// the repo's installer refreshes this package's lockfile). +export const NPM_PKG_DIR = path.join(REPO_ROOT, 'docs') + +// Lockfile refreshers tried in order after taze rewrites package.json. +export const NPM_INSTALLERS: string[][] = [ + [path.join(REPO_ROOT, 'target/debug/aube'), 'install'], + ['aube', 'install'], +] + +// rustup's cargo shim — the only cargo that reads rust-toolchain.toml and +// therefore the only one whose `cargo update` honors the [unstable] +// min-publish-age soak. +export const RUSTUP_CARGO = path.join(os.homedir(), '.cargo/bin/cargo') + +// Pinned external tool manifest + the local tool rack it installs into: +// exact versions under rack///, flat PATH handles in bin/. +export const EXTERNAL_TOOLS_JSON = path.join(REPO_ROOT, 'external-tools.json') + +// CI agent image that pre-bakes the pinned toolchain + sfw (null when the +// repo has no such image). The image builder's context is .buildkite/ +// alone, so the Dockerfile can't COPY the tracked pin sources — it embeds +// copies, and external-tools.mts --check asserts they haven't drifted. +export const DOCKER_PREBAKE: string | null = '.buildkite/linux-agent.Dockerfile' + +// .dockerignore managed by `untracked` (null when the repo has no +// repo-context docker builds — aube's agent image never copies the repo). +export const DOCKERIGNORE: string | null = null + +const XDG_DATA_HOME = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share') +export const DEV_TOOLS_DIR = path.join(XDG_DATA_HOME, 'aube/dev-tools') +export const RACK_DIR = path.join(DEV_TOOLS_DIR, 'rack') +export const BIN_DIR = path.join(DEV_TOOLS_DIR, 'bin') + + +// Candidates (tried in order) for installing an extracted external tool's +// runtime deps — the repo's own package manager first, of course. +export const PM_DEP_INSTALLERS: string[][] = [ + [path.join(REPO_ROOT, 'target/debug/aube'), 'install', '--prod'], + ['aube', 'install', '--prod'], + ['pnpm', 'install', '--prod', '--ignore-scripts'], + ['npm', 'install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], +] diff --git a/scripts/soak/rack.test.mts b/scripts/soak/rack.test.mts new file mode 100644 index 000000000..640dbf304 --- /dev/null +++ b/scripts/soak/rack.test.mts @@ -0,0 +1,81 @@ +// Filesystem-touching tests run against a throwaway rack under os.tmpdir(): +// XDG_DATA_HOME is pointed at a fresh temp dir BEFORE paths.mts is imported +// (dynamic imports below), so nothing here can touch the real rack. +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { after, test } from 'node:test' + +const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'soak-rack-test-')) +process.env.XDG_DATA_HOME = SANDBOX + +const { linkHandle, rackRealFor, writeShims } = await import('./external-tools.mts') +const { BIN_DIR, RACK_DIR } = await import('./paths.mts') + +after(() => { + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +function plantRackBinary(cmd: string, version: string): string { + const dir = path.join(RACK_DIR, cmd, version) + mkdirSync(dir, { recursive: true }) + const bin = path.join(dir, cmd) + writeFileSync(bin, '#!/usr/bin/env bash\necho real-binary\n') + return bin +} + +test('the sandbox rack lives under os.tmpdir()', () => { + assert.ok(BIN_DIR.startsWith(os.tmpdir().replace(/\/$/, '')) || BIN_DIR.startsWith(SANDBOX)) +}) + +test('linkHandle replaces existing and DANGLING handles without throwing', () => { + const bin = plantRackBinary('fakea', '1.0.0') + linkHandle(bin, 'fakea') + // replace an existing handle + linkHandle(bin, 'fakea') + // dangling: remove the target, the handle now points nowhere + rmSync(bin) + const bin2 = plantRackBinary('fakea', '2.0.0') + linkHandle(bin2, 'fakea') + assert.equal(readFileSync(path.join(BIN_DIR, 'fakea'), 'utf8').includes('real-binary'), true) +}) + +test('rackRealFor resolves version binaries and wrappers, else empty', () => { + const bin = plantRackBinary('fakeb', '3.1.4') + assert.equal(rackRealFor('fakeb', { fakeb: { version: '3.1.4' } }), bin) + const wrapper = path.join(RACK_DIR, 'fakec', 'fakec-wrapper') + mkdirSync(path.dirname(wrapper), { recursive: true }) + writeFileSync(wrapper, '#!/usr/bin/env bash\n') + assert.equal(rackRealFor('fakec', { fakec: { purl: 'pkg:npm/fakec@1.0.0' } }), wrapper) + assert.equal(rackRealFor('missing', {}), '') +}) + +test('writeShims never clobbers a rack binary and embeds its path', t => { + if (process.platform === 'win32') { + t.skip('shims are POSIX-only') + return + } + const tools = { pnpm: { version: '11.8.0' } } + const bin = plantRackBinary('pnpm', '11.8.0') + linkHandle(bin, 'pnpm') + writeShims(tools) + // regression (writing through the symlink): the rack binary must be intact + assert.match(readFileSync(bin, 'utf8'), /real-binary/) + const shim = readFileSync(path.join(BIN_DIR, 'pnpm'), 'utf8') + assert.ok(shim.includes(`REAL='${bin}'`), 'pinned command embeds its rack path') + // unpinned commands fall back to PATH-stripping resolution + assert.match(readFileSync(path.join(BIN_DIR, 'cargo'), 'utf8'), /CLEAN_PATH/) +}) + +test('writeShims is idempotent: a re-run keeps the rack pin embedded', t => { + if (process.platform === 'win32') { + t.skip('shims are POSIX-only') + return + } + const tools = { pnpm: { version: '11.8.0' } } + writeShims(tools) + writeShims(tools) // handle is now a regular shim file, not a symlink + const shim = readFileSync(path.join(BIN_DIR, 'pnpm'), 'utf8') + assert.ok(shim.includes("REAL='"), 'rack pin survives a second --shims run') +}) diff --git a/scripts/soak/remotes.mts b/scripts/soak/remotes.mts new file mode 100644 index 000000000..7afa4f6fb --- /dev/null +++ b/scripts/soak/remotes.mts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * @file 1 remote map, 1 reference — where this repo's PRs live and where + * branches push, declared in code instead of rediscovered. The two homes + * differ here (PRs open on the upstream, branches push to a fork), and + * tooling that guesses wrong burns API round-trips proving 404s — worse + * when gh auth is unavailable and the only fallback is the anonymous API, + * which answers only on the PR's BASE repo (and needs the full 40-char + * SHA for check-runs). + * + * Usage: node scripts/soak/remotes.mts [--check|--fix|--print] + * --check verifies the clone's git remotes match the declaration + * (skipped in CI — runner checkouts legitimately differ), --fix adds or + * repoints the `fork` remote (origin drift is report-only: repointing + * origin is a human decision), --print emits the map as JSON for + * tooling. + */ + +import { spawnSync } from 'node:child_process' +import process from 'node:process' +import { realpathSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +import { REPO_ROOT } from './paths.mts' + +// PRs open on `origin` (the upstream); work branches push to `fork`. +// Protocol is irrelevant — only owner/repo is law — but --fix writes SSH +// because it keeps working when gh's stored https credentials get wiped. +export const PR_HOME = 'jdx/aube' +export const PUSH_HOME = 'jdalton/aube' +export const EXPECTED_REMOTES: Record = { + origin: PR_HOME, + fork: PUSH_HOME, +} + +/** + * Reduce a GitHub remote URL to its `owner/repo` identity: https, ssh + * (scp-like and ssh://), with or without `.git`. Null for anything that is + * not github.com — a non-GitHub URL on a declared remote is drift, not a + * parse error. + */ +export function normalizeGitHubRepo(url: string): string | null { + const m = + /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim()) + return m ? m[1]! : null +} + +/** + * Parse `git remote -v` output into name -> fetch URL. + */ +export function parseRemotes(gitRemoteOutput: string): Record { + const remotes: Record = {} + for (const line of gitRemoteOutput.split('\n')) { + const m = /^(\S+)\t(\S+)\s+\(fetch\)$/.exec(line) + if (m) { + remotes[m[1]!] = m[2]! + } + } + return remotes +} + +export interface RemoteDrift { + name: string + expected: string + actual: string | null +} + +/** + * Every declared remote must exist and resolve to its declared owner/repo + * (case-insensitively — GitHub is). Undeclared extra remotes are fine. + */ +export function checkRemotes(remotes: Record): RemoteDrift[] { + const drift: RemoteDrift[] = [] + for (const [name, expected] of Object.entries(EXPECTED_REMOTES)) { + const url = remotes[name] + const actual = url === undefined ? null : normalizeGitHubRepo(url) + if (actual?.toLowerCase() !== expected.toLowerCase()) { + drift.push({ name, expected, actual }) + } + } + return drift +} + +function readClone(): Record { + const res = spawnSync('git', ['remote', '-v'], { cwd: REPO_ROOT, encoding: 'utf8' }) + if (res.status !== 0) { + console.error(`[remotes] git remote -v failed: ${res.stderr?.trim() || res.error?.message}`) + process.exit(1) + } + return parseRemotes(res.stdout) +} + +function main(argv: string[] = process.argv.slice(2)): number { + if (argv.includes('--print')) { + console.log(JSON.stringify({ prHome: PR_HOME, pushHome: PUSH_HOME, pushRemote: 'fork' })) + return 0 + } + const fix = argv.includes('--fix') + if (!fix && process.env.CI) { + console.log('[remotes] CI checkout — remote-map law is a dev-machine guard, skipping') + return 0 + } + const drift = checkRemotes(readClone()) + if (!drift.length) { + console.log(`[remotes] ok — PRs: ${PR_HOME} (origin), pushes: ${PUSH_HOME} (fork)`) + return 0 + } + let unfixed = 0 + for (const d of drift) { + if (fix && d.name === 'fork') { + const url = `git@github.com:${EXPECTED_REMOTES['fork']}.git` + const args = d.actual === null && !readClone()['fork'] + ? ['remote', 'add', 'fork', url] + : ['remote', 'set-url', 'fork', url] + const res = spawnSync('git', args, { cwd: REPO_ROOT, stdio: 'inherit' }) + if (res.status === 0) { + console.log(`[remotes] fixed: fork -> ${url}`) + continue + } + } + unfixed += 1 + console.error( + `[remotes] drift: remote '${d.name}' should be github.com/${d.expected}, ` + + `found ${d.actual ?? 'missing/non-github'}` + + (d.name === 'fork' ? ' (run with --fix)' : ' (repoint manually — origin is never auto-rewritten)'), + ) + } + return unfixed ? 1 : 0 +} + +const isMain = + process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url +if (isMain) { + process.exitCode = main() +} diff --git a/scripts/soak/remotes.test.mts b/scripts/soak/remotes.test.mts new file mode 100644 index 000000000..3aef02dd2 --- /dev/null +++ b/scripts/soak/remotes.test.mts @@ -0,0 +1,71 @@ +/** + * @file Unit tests for the remote-map law's pure core: URL identity + * normalization, `git remote -v` parsing, and drift classification — + * both arms of each. The git/process phases stay behind these functions + * so the suite runs offline on any clone. + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + EXPECTED_REMOTES, + PR_HOME, + PUSH_HOME, + checkRemotes, + normalizeGitHubRepo, + parseRemotes, +} from './remotes.mts' + +test('normalizeGitHubRepo accepts https, scp-ssh, ssh://, with/without .git', () => { + assert.equal(normalizeGitHubRepo('https://github.com/jdx/aube.git'), 'jdx/aube') + assert.equal(normalizeGitHubRepo('https://github.com/jdx/aube'), 'jdx/aube') + assert.equal(normalizeGitHubRepo('git@github.com:jdx/aube.git'), 'jdx/aube') + assert.equal(normalizeGitHubRepo('ssh://git@github.com/jdx/aube'), 'jdx/aube') + assert.equal(normalizeGitHubRepo('https://github.com/jdx/aube/'), 'jdx/aube') +}) + +test('normalizeGitHubRepo rejects non-github and malformed URLs', () => { + assert.equal(normalizeGitHubRepo('https://gitlab.com/jdx/aube.git'), null) + assert.equal(normalizeGitHubRepo('git@github.com:aube.git'), null) + assert.equal(normalizeGitHubRepo('/local/path/aube'), null) +}) + +test('parseRemotes keeps one fetch URL per remote and ignores push lines', () => { + const remotes = parseRemotes( + [ + 'origin\thttps://github.com/jdx/aube.git (fetch)', + 'origin\thttps://github.com/jdx/aube.git (push)', + 'fork\tgit@github.com:jdalton/aube.git (fetch)', + 'fork\tgit@github.com:jdalton/aube.git (push)', + '', + ].join('\n'), + ) + assert.deepEqual(remotes, { + origin: 'https://github.com/jdx/aube.git', + fork: 'git@github.com:jdalton/aube.git', + }) +}) + +test('checkRemotes passes when declared remotes match, any protocol or case', () => { + assert.deepEqual( + checkRemotes({ + origin: `https://github.com/${PR_HOME}.git`, + fork: `git@github.com:${PUSH_HOME.toUpperCase()}.git`, + extra: 'https://github.com/somebody/else.git', + }), + [], + ) +}) + +test('checkRemotes flags a missing fork and a mispointed origin', () => { + const drift = checkRemotes({ origin: 'https://github.com/wrong/home.git' }) + assert.deepEqual(drift, [ + { name: 'origin', expected: PR_HOME, actual: 'wrong/home' }, + { name: 'fork', expected: PUSH_HOME, actual: null }, + ]) +}) + +test('the declared map covers exactly the PR home and the push fork', () => { + assert.deepEqual(EXPECTED_REMOTES, { origin: PR_HOME, fork: PUSH_HOME }) +}) diff --git a/scripts/soak/soak.mts b/scripts/soak/soak.mts new file mode 100644 index 000000000..aa1a4c229 --- /dev/null +++ b/scripts/soak/soak.mts @@ -0,0 +1,497 @@ +#!/usr/bin/env node +/** + * @file Soak manager — parity gate + fixer for the release-age cooldown. + * The window is ONE value (`SOAK_DAYS` in ./constants.mts); this script + * asserts every data surface matches it and that soak exclusions carry a + * valid, unexpired `# published: | removable:` annotation: + * + * - `.cargo/config.toml` `global-min-publish-age` + `[unstable] min-publish-age` + * - `rust-toolchain.toml` nightly channel vs `# adopted:` (dated-nightly soak) + * - `docs/pnpm-workspace.yaml` `minimumReleaseAge` (minutes) + annotated excludes + * - `docs/.npmrc` `min-release-age` (days) + * - `docs/taze.config.mts` imports SOAK_DAYS (existence + import check) + * - `.github/renovate.json` `minimumReleaseAge` ("N days", explicit — not preset-inherited) + * + * `--check` (default) fails loud with What / Saw / Wanted / Fix on drift. + * `--fix` rewrites window values in place and prunes excludes whose + * `removable` date has passed (a cleared pin is dead weight — pruning it + * re-arms the soak for the next publish of that package). + * + * Usage: node scripts/soak/soak.mts [--check|--fix] [--quiet] + */ + +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { + ANNOTATION_RE, + SOAK_DAYS, + SOAK_MINUTES, + VERSION_PIN_RE, + addDaysIso, + isValidIsoDate, + todayIso, +} from './constants.mts' +import { REPO_ROOT, SURFACES } from './paths.mts' + +export interface Finding { + file: string + what: string + saw: string + wanted: string + fix: string +} + +export function checkCargoConfig(body: string, file: string): Finding[] { + const out: Finding[] = [] + const age = /^global-min-publish-age\s*=\s*"([^"]*)"/m.exec(body)?.[1] + const wanted = `${SOAK_DAYS} days` + if (age !== wanted) { + out.push({ + file, + what: 'cargo min-publish-age window', + saw: age ?? '(missing)', + wanted, + fix: `set [registry] global-min-publish-age = "${wanted}" (or run --fix)`, + }) + } + if (!/^\[unstable\][^[]*^min-publish-age\s*=\s*true/ms.test(body)) { + out.push({ + file, + what: 'cargo unstable feature gate', + saw: '[unstable] min-publish-age missing or false', + wanted: 'min-publish-age = true under [unstable]', + fix: 'add `[unstable]\\nmin-publish-age = true` (nightly-only; the pinned toolchain provides it)', + }) + } + return out +} + +export function checkNpmrc(body: string, file: string): Finding[] { + const days = /^min-release-age=(\d+)\s*$/m.exec(body)?.[1] + if (Number(days) === SOAK_DAYS) { + return [] + } + return [ + { + file, + what: 'npm min-release-age window', + saw: days ?? '(missing)', + wanted: String(SOAK_DAYS), + fix: `set min-release-age=${SOAK_DAYS} (or run --fix)`, + }, + ] +} + +export function checkWorkspaceYaml(body: string, file: string): Finding[] { + const out: Finding[] = [] + const minutes = /^minimumReleaseAge:\s*(\d+)\s*$/m.exec(body)?.[1] + if (Number(minutes) !== SOAK_MINUTES) { + out.push({ + file, + what: 'minimumReleaseAge window', + saw: minutes ?? '(missing)', + wanted: `${SOAK_MINUTES} (SOAK_DAYS ${SOAK_DAYS} x 1440 minutes)`, + fix: `set minimumReleaseAge: ${SOAK_MINUTES} (or run --fix)`, + }) + } + out.push(...checkExcludeAnnotations(body, file)) + return out +} + +/** + * Every version-pinned `minimumReleaseAgeExclude` entry must carry, on the + * line directly above, `# published: YYYY-MM-DD | removable: YYYY-MM-DD` + * with `removable = published + SOAK_DAYS`, and must be pruned once + * `removable` is strictly in the past. Bare names and `@scope/*` globs are + * standing trust, not dated bypasses — no annotation required. + */ +export function checkExcludeAnnotations(body: string, file: string): Finding[] { + const out: Finding[] = [] + const today = todayIso() + // Flow style would be invisible to the block parser below — an + // unvalidated, never-expiring bypass. One canonical shape only. + if (/^minimumReleaseAgeExclude:\s*\[/m.test(body)) { + out.push({ + file, + what: 'minimumReleaseAgeExclude flow style', + saw: 'inline [...] list', + wanted: 'a block list (one annotated `- entry` per line)', + fix: 'rewrite as a block list so every pin can carry its annotation', + }) + return out + } + for (const entry of parseExcludeEntries(body)) { + if (!VERSION_PIN_RE.test(entry.name)) { + continue + } + if (!entry.annotation) { + out.push({ + file, + what: `soak exclude '${entry.name}' annotation`, + saw: '(no annotation on the line above)', + wanted: `# published: YYYY-MM-DD | removable: `, + fix: `annotate the pin with its real registry publish date`, + }) + continue + } + const { published, removable } = entry.annotation + if (!isValidIsoDate(published) || !isValidIsoDate(removable)) { + out.push({ + file, + what: `soak exclude '${entry.name}' annotation dates`, + saw: `${published} | ${removable}`, + wanted: 'real YYYY-MM-DD calendar dates', + fix: 'correct the annotation to the real registry publish date', + }) + continue + } + const expected = addDaysIso(published, SOAK_DAYS) + if (removable !== expected) { + out.push({ + file, + what: `soak exclude '${entry.name}' removable date`, + saw: removable, + wanted: `${expected} (published ${published} + ${SOAK_DAYS} days)`, + fix: 'correct the removable date', + }) + } + if (removable < today) { + out.push({ + file, + what: `soak exclude '${entry.name}' expired`, + saw: `removable ${removable} < today ${today}`, + wanted: 'entry pruned once its window has passed', + fix: 'delete the pin + its annotation (or run --fix)', + }) + } + } + return out +} + +interface ExcludeEntry { + name: string + line: number + annotation?: { published: string; removable: string } +} + +export function parseExcludeEntries(body: string): ExcludeEntry[] { + const lines = body.split('\n') + const out: ExcludeEntry[] = [] + let inBlock = false + let blockIndent = 0 + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + if (/^minimumReleaseAgeExclude:\s*$/.test(line)) { + inBlock = true + blockIndent = -1 + continue + } + if (!inBlock) { + continue + } + const item = /^(\s+)-\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/.exec(line) + if (!item) { + // Comments stay inside the block; anything else at column 0 ends it. + if (/^\S/.test(line)) { + inBlock = false + } + continue + } + if (blockIndent === -1) { + blockIndent = item[1]!.length + } + if (item[1]!.length !== blockIndent) { + continue + } + const prev = lines[i - 1]?.trim() ?? '' + const ann = ANNOTATION_RE.exec(prev) + out.push({ + name: item[2]!, + line: i + 1, + ...(ann ? { annotation: { published: ann[1]!, removable: ann[2]! } } : {}), + }) + } + return out +} + +/** + * Catalog-shadowed pins stay in lockstep: when a package.json next to the + * workspace yaml pins a cataloged package to an exact version instead of + * `catalog:` (npm-cli compat — npm can't parse the protocol), the two + * versions must match. `catalog:` references no-op here. + */ +export function checkCatalogParity( + yamlBody: string, + pkgJson: string, + yamlFile: string, +): Finding[] { + const out: Finding[] = [] + const catalog: Record = {} + const block = /^catalog:\s*\n((?:[ \t]+\S.*\n?|\s*\n)*)/m.exec(yamlBody)?.[1] ?? '' + for (const m of block.matchAll(/^[ \t]+['"]?([^'":\s]+)['"]?:\s*['"]?([^'"\s]+)['"]?\s*$/gm)) { + catalog[m[1]!] = m[2]! + } + const pkg = JSON.parse(pkgJson) + const declared: Record = { + ...pkg.dependencies, + ...pkg.devDependencies, + } + for (const [name, version] of Object.entries(catalog)) { + const spec = declared[name] + if (spec === undefined || spec === 'catalog:' || spec === version) { + continue + } + out.push({ + file: yamlFile, + what: `catalog-shadowed pin '${name}' out of lockstep`, + saw: `catalog ${version} vs package.json ${spec}`, + wanted: 'identical versions (or a catalog: reference)', + fix: `bump both together — the catalog entry is the reference`, + }) + } + return out +} + +/** + * The toolchain pin obeys the same soak: a dated nightly must have been at + * least SOAK_DAYS old on its recorded adoption date (`# adopted:` line, + * machine-read here). Stable channel pins carry no date and pass freely. + */ +export function checkToolchainSoak(body: string, file: string): Finding[] { + const channelDate = /^channel\s*=\s*"nightly-(\d{4}-\d{2}-\d{2})"/m.exec(body)?.[1] + if (!channelDate) { + return [] + } + const adopted = /^#\s*adopted:\s*(\d{4}-\d{2}-\d{2})\s*$/m.exec(body)?.[1] + if (!adopted) { + return [ + { + file, + what: 'toolchain adoption date', + saw: '(no `# adopted: YYYY-MM-DD` line)', + wanted: 'a recorded adoption date so the nightly soak is checkable', + fix: 'add `# adopted: ` above [toolchain]', + }, + ] + } + if (!isValidIsoDate(channelDate) || !isValidIsoDate(adopted)) { + return [ + { + file, + what: 'toolchain soak dates', + saw: `${channelDate} | ${adopted}`, + wanted: 'real YYYY-MM-DD calendar dates', + fix: 'correct the nightly channel / adopted dates', + }, + ] + } + if (addDaysIso(channelDate, SOAK_DAYS) > adopted) { + return [ + { + file, + what: 'toolchain nightly soak', + saw: `nightly-${channelDate} adopted ${adopted}`, + wanted: `a nightly at least ${SOAK_DAYS} days old at adoption`, + fix: 'pin the newest nightly that had cleared the window on the adoption date', + }, + ] + } + return [] +} + +export function checkTazeConfig(body: string, file: string): Finding[] { + const out: Finding[] = [] + if (!body.includes('maturityPeriod')) { + out.push({ + file, + what: 'taze maturityPeriod', + saw: '(not set)', + wanted: 'maturityPeriod: SOAK_DAYS', + fix: 'set maturityPeriod: SOAK_DAYS in the taze config', + }) + } + if (!body.includes('constants.mts')) { + out.push({ + file, + what: 'taze config soak import', + saw: 'window not imported from scripts/soak/constants.mts', + wanted: "import { SOAK_DAYS } from '/scripts/soak/constants.mts'", + fix: 'import SOAK_DAYS instead of hand-copying the number', + }) + } + return out +} + +/** + * Renovate must carry the window EXPLICITLY in this repo's renovate.json. + * Renovate bumps manifests + lockfiles server-side, and cargo's + * min-publish-age skips already-locked versions — so a Renovate PR is the + * one dependency path none of the local soak surfaces can stop. An + * inherited preset value (extends:) doesn't count: presets change without + * a commit here, which is exactly the silent drift this gate exists to + * catch. + */ +export function checkRenovateConfig(body: string, file: string): Finding[] { + let config: Record + try { + config = JSON.parse(body) + } catch { + return [ + { + file, + what: 'renovate config parse', + saw: '(invalid JSON)', + wanted: 'parseable JSON carrying the soak window', + fix: 'repair the JSON, then set minimumReleaseAge (or run --fix)', + }, + ] + } + const wanted = `${SOAK_DAYS} days` + const saw = config['minimumReleaseAge'] + if (SOAK_DAYS === 0 ? saw === undefined : saw === wanted) { + return [] + } + return [ + { + file, + what: 'renovate minimumReleaseAge window', + saw: saw === undefined ? '(missing — an extends: preset does not count)' : String(saw), + wanted: SOAK_DAYS === 0 ? '(absent — soak disabled)' : wanted, + fix: `set "minimumReleaseAge": "${wanted}" at the top level (or run --fix)`, + }, + ] +} + +export function fixRenovateConfig(body: string): string { + let config: Record + try { + config = JSON.parse(body) + } catch { + return body + } + if (SOAK_DAYS === 0) { + delete config['minimumReleaseAge'] + } else { + config['minimumReleaseAge'] = `${SOAK_DAYS} days` + } + return `${JSON.stringify(config, null, 2)}\n` +} + +export function fixCargoConfig(body: string): string { + return body.replace( + /^(global-min-publish-age\s*=\s*)"[^"]*"/m, + `$1"${SOAK_DAYS} days"`, + ) +} + +export function fixNpmrc(body: string): string { + if (/^min-release-age=\d+\s*$/m.test(body)) { + return body.replace(/^min-release-age=\d+\s*$/m, `min-release-age=${SOAK_DAYS}`) + } + return `${body.trimEnd()}\nmin-release-age=${SOAK_DAYS}\n` +} + +export function fixWorkspaceYaml(body: string): string { + let out = body.replace( + /^(minimumReleaseAge:\s*)\d+\s*$/m, + `$1${SOAK_MINUTES}`, + ) + // Prune expired pins together with their annotation line. + const today = todayIso() + const lines = out.split('\n') + const drop = new Set() + for (const entry of parseExcludeEntries(out)) { + if (entry.annotation && entry.annotation.removable < today) { + drop.add(entry.line - 1) + if (ANNOTATION_RE.test(lines[entry.line - 2]?.trim() ?? '')) { + drop.add(entry.line - 2) + } + } + } + if (drop.size > 0) { + out = lines.filter((_, i) => !drop.has(i)).join('\n') + } + return out +} + +function report(findings: Finding[], quiet: boolean): void { + for (const f of findings) { + console.error(`[soak] ${f.file}: ${f.what}`) + console.error(` saw: ${f.saw}`) + console.error(` wanted: ${f.wanted}`) + console.error(` fix: ${f.fix}`) + } + if (!quiet && findings.length === 0) { + console.log(`[soak] all surfaces match SOAK_DAYS=${SOAK_DAYS} and no exclude has drifted`) + } +} + +export function main(argv: string[] = process.argv.slice(2)): number { + const fix = argv.includes('--fix') + const quiet = argv.includes('--quiet') + const findings: Finding[] = [] + + const surfaces: Array<{ + rel: string + check: (body: string, file: string) => Finding[] + fixer?: (body: string) => string + }> = [ + { rel: SURFACES.cargoConfig, check: checkCargoConfig, fixer: fixCargoConfig }, + { rel: SURFACES.npmrc, check: checkNpmrc, fixer: fixNpmrc }, + { rel: SURFACES.workspaceYaml, check: checkWorkspaceYaml, fixer: fixWorkspaceYaml }, + { rel: SURFACES.tazeConfig, check: checkTazeConfig }, + { rel: SURFACES.toolchainToml, check: checkToolchainSoak }, + { rel: SURFACES.renovateJson, check: checkRenovateConfig, fixer: fixRenovateConfig }, + ] + + for (const s of surfaces) { + const abs = path.join(REPO_ROOT, s.rel) + if (!existsSync(abs)) { + findings.push({ + file: s.rel, + what: 'soak surface missing', + saw: '(file absent)', + wanted: 'file present and carrying the soak window', + fix: `create ${s.rel} — see scripts/soak/constants.mts header for the expected key`, + }) + continue + } + let body = readFileSync(abs, 'utf8') + if (fix && s.fixer) { + const fixed = s.fixer(body) + if (fixed !== body) { + writeFileSync(abs, fixed) + console.log(`[soak] fixed ${s.rel}`) + body = fixed + } + } + findings.push(...s.check(body, s.rel)) + } + + // Catalog <-> package.json lockstep for the package next to the yaml. + const yamlAbs = path.join(REPO_ROOT, SURFACES.workspaceYaml) + const pkgAbs = path.join(path.dirname(yamlAbs), 'package.json') + if (existsSync(yamlAbs) && existsSync(pkgAbs)) { + findings.push( + ...checkCatalogParity( + readFileSync(yamlAbs, 'utf8'), + readFileSync(pkgAbs, 'utf8'), + SURFACES.workspaceYaml, + ), + ) + } + + report(findings, quiet) + return findings.length === 0 ? 0 : 1 +} + +// realpath + pathToFileURL so symlinked checkouts and paths needing URL +// encoding still register as the entrypoint (ESM realpaths import.meta.url). +const isMain = + process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url +if (isMain) { + process.exitCode = main() +} diff --git a/scripts/soak/soak.test.mts b/scripts/soak/soak.test.mts new file mode 100644 index 000000000..d80688823 --- /dev/null +++ b/scripts/soak/soak.test.mts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { SOAK_DAYS, addDaysIso, todayIso } from './constants.mts' +import { + checkCargoConfig, + checkCatalogParity, + checkExcludeAnnotations, + checkNpmrc, + checkRenovateConfig, + checkTazeConfig, + checkToolchainSoak, + checkWorkspaceYaml, + fixCargoConfig, + fixNpmrc, + fixRenovateConfig, + fixWorkspaceYaml, + main, + parseExcludeEntries, +} from './soak.mts' + +// A pin published yesterday is inside its window; one published long ago +// has expired. Built relative to today so the tests never go stale. +const FRESH_PUB = addDaysIso(todayIso(), -1) +const FRESH_REM = addDaysIso(FRESH_PUB, SOAK_DAYS) + +const CLEAN_YAML = `catalog: + taze: 19.14.1 +minimumReleaseAge: 10080 +minimumReleaseAgeExclude: + # published: ${FRESH_PUB} | removable: ${FRESH_REM} + - 'left-pad@1.3.0' + - '@myorg/*' + - react +` + +test('cargo config: wrong window and missing unstable gate are findings', () => { + const good = '[unstable]\nmin-publish-age = true\n\n[registry]\nglobal-min-publish-age = "7 days"\n' + assert.equal(checkCargoConfig(good, 'c').length, 0) + assert.equal(checkCargoConfig(good.replace('7 days', '3 days'), 'c').length, 1) + assert.equal(checkCargoConfig('[registry]\nglobal-min-publish-age = "7 days"\n', 'c').length, 1) +}) + +test('npmrc: window must match SOAK_DAYS and fix writes it', () => { + assert.equal(checkNpmrc('min-release-age=7\n', 'n').length, 0) + assert.equal(checkNpmrc('min-release-age=3\n', 'n').length, 1) + assert.equal(checkNpmrc('# nothing\n', 'n').length, 1) + assert.match(fixNpmrc('# nothing\n'), /min-release-age=7/) + assert.match(fixNpmrc('min-release-age=3\n'), /min-release-age=7/) +}) + +test('workspace yaml: clean fixture passes', () => { + assert.deepEqual(checkWorkspaceYaml(CLEAN_YAML, 'y'), []) +}) + +test('workspace yaml: wrong minutes value is a finding', () => { + const bad = CLEAN_YAML.replace('10080', '1440') + assert.equal(checkWorkspaceYaml(bad, 'y').filter(f => f.what.includes('minimumReleaseAge')).length, 1) +}) + +test('excludes: flow-style list is rejected outright', () => { + const flow = "minimumReleaseAge: 10080\nminimumReleaseAgeExclude: ['left-pad@1.3.0']\n" + const findings = checkExcludeAnnotations(flow, 'y') + assert.equal(findings.length, 1) + assert.match(findings[0]!.what, /flow style/) +}) + +test('excludes: unannotated version pin is a finding, bare/glob are not', () => { + const yaml = 'minimumReleaseAgeExclude:\n - lodash@4.17.21\n - react\n - "@myorg/*"\n' + const findings = checkExcludeAnnotations(yaml, 'y') + assert.equal(findings.length, 1) + assert.match(findings[0]!.what, /lodash@4\.17\.21/) +}) + +test('excludes: wrong removable date and expiry are findings', () => { + const wrong = `minimumReleaseAgeExclude:\n # published: ${FRESH_PUB} | removable: ${addDaysIso(FRESH_PUB, 3)}\n - 'a@1.0.0'\n` + assert.match(checkExcludeAnnotations(wrong, 'y')[0]!.what, /removable date/) + const expired = `minimumReleaseAgeExclude:\n # published: 2020-01-01 | removable: 2020-01-08\n - 'b@1.0.0'\n` + assert.match(checkExcludeAnnotations(expired, 'y')[0]!.what, /expired/) +}) + +test('excludes: impossible calendar dates are findings, not crashes', () => { + const bad = `minimumReleaseAgeExclude:\n # published: 2026-13-45 | removable: 2026-13-52\n - 'c@1.0.0'\n` + const findings = checkExcludeAnnotations(bad, 'y') + assert.equal(findings.length, 1) + assert.match(findings[0]!.what, /annotation dates/) +}) + +test('excludes: entries with trailing comments still parse', () => { + const yaml = `minimumReleaseAgeExclude:\n # published: ${FRESH_PUB} | removable: ${FRESH_REM}\n - 'd@2.0.0' # temp\n` + assert.deepEqual(parseExcludeEntries(yaml).map(e => e.name), ['d@2.0.0']) + assert.equal(checkExcludeAnnotations(yaml, 'y').length, 0) +}) + +test('fix prunes expired pins together with their annotations', () => { + const yaml = `minimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n # published: 2020-01-01 | removable: 2020-01-08\n - 'old@1.0.0'\n # published: ${FRESH_PUB} | removable: ${FRESH_REM}\n - 'fresh@1.0.0'\n` + const fixed = fixWorkspaceYaml(yaml) + assert.ok(!fixed.includes('old@1.0.0')) + assert.ok(!fixed.includes('2020-01-01')) + assert.ok(fixed.includes('fresh@1.0.0')) +}) + +test('catalog parity: exact pin must match, catalog: protocol no-ops', () => { + const yaml = 'catalog:\n taze: 19.14.1\n' + const pin = (v: string) => JSON.stringify({ devDependencies: { taze: v } }) + assert.equal(checkCatalogParity(yaml, pin('19.14.1'), 'y').length, 0) + assert.equal(checkCatalogParity(yaml, pin('19.14.2'), 'y').length, 1) + assert.equal(checkCatalogParity(yaml, pin('catalog:'), 'y').length, 0) +}) + +test('catalog parity: entries after a blank line are still checked', () => { + const yaml = 'catalog:\n taze: 19.14.1\n\n untracked: 1.6.4\n' + const pkg = JSON.stringify({ devDependencies: { taze: '19.14.1', untracked: '1.0.0' } }) + assert.equal(checkCatalogParity(yaml, pkg, 'y').length, 1) +}) + +test('taze config: window must be imported, not hand-copied', () => { + const good = "import { SOAK_DAYS } from './scripts/soak/constants.mts'\nexport default { maturityPeriod: SOAK_DAYS }\n" + assert.equal(checkTazeConfig(good, 't').length, 0) + assert.equal(checkTazeConfig('export default { maturityPeriod: 7 }\n', 't').length, 1) + assert.equal(checkTazeConfig('export default {}\n', 't').length, 2) +}) + +test('toolchain soak: nightly must be SOAK_DAYS old at adoption; stable passes', () => { + const good = '# adopted: 2026-07-11\n[toolchain]\nchannel = "nightly-2026-07-04"\n' + assert.equal(checkToolchainSoak(good, 't').length, 0) + const tooFresh = '# adopted: 2026-07-11\n[toolchain]\nchannel = "nightly-2026-07-08"\n' + assert.match(checkToolchainSoak(tooFresh, 't')[0]!.what, /nightly soak/) + const noDate = '[toolchain]\nchannel = "nightly-2026-07-04"\n' + assert.match(checkToolchainSoak(noDate, 't')[0]!.what, /adoption date/) + const stable = '[toolchain]\nchannel = "1.95.0"\n' + assert.equal(checkToolchainSoak(stable, 't').length, 0) +}) + +test('toolchain soak: impossible calendar dates are findings, not crashes', () => { + const bad = '# adopted: 2026-13-45\n[toolchain]\nchannel = "nightly-2026-07-04"\n' + assert.match(checkToolchainSoak(bad, 't')[0]!.what, /soak dates/) +}) + +test('parser: a column-0 line ends the exclude block', () => { + const yaml = 'minimumReleaseAgeExclude:\n - react\nonlyBuiltDependencies:\n - esbuild\n' + assert.deepEqual(parseExcludeEntries(yaml).map(e => e.name), ['react']) +}) + +test('parser: items at a different indent are not exclude entries', () => { + const yaml = 'minimumReleaseAgeExclude:\n - react\n - not-an-entry\n - vue\n' + assert.deepEqual(parseExcludeEntries(yaml).map(e => e.name), ['react', 'vue']) +}) + +test('fix rewrites a drifted cargo window and leaves a clean one alone', () => { + const fixed = fixCargoConfig('[registry]\nglobal-min-publish-age = "3 days"\n') + assert.ok(fixed.includes(`"${SOAK_DAYS} days"`)) + assert.equal(fixCargoConfig(fixed), fixed) +}) + +test('renovate: window must be explicit in-repo; preset inheritance is drift', () => { + const good = `{ "extends": ["some>preset"], "minimumReleaseAge": "${SOAK_DAYS} days" }` + assert.equal(checkRenovateConfig(good, 'r').length, 0) + // Missing key = inherited-at-best: the preset can change without a + // commit here, so the gate demands the explicit value. + assert.equal(checkRenovateConfig('{ "extends": ["some>preset"] }', 'r').length, 1) + assert.equal(checkRenovateConfig('{ "minimumReleaseAge": "3 days" }', 'r').length, 1) + assert.equal(checkRenovateConfig('{ "minimumReleaseAge": 7 }', 'r').length, 1) + assert.equal(checkRenovateConfig('not json', 'r').length, 1) +}) + +test('renovate fix sets the window, preserves other keys, and is idempotent', () => { + const fixed = fixRenovateConfig('{\n "labels": ["dependencies"],\n "minimumReleaseAge": "3 days"\n}\n') + const parsed = JSON.parse(fixed) + assert.equal(parsed.minimumReleaseAge, `${SOAK_DAYS} days`) + assert.deepEqual(parsed.labels, ['dependencies']) + assert.equal(fixRenovateConfig(fixed), fixed) + assert.equal(JSON.parse(fixRenovateConfig('{}')).minimumReleaseAge, `${SOAK_DAYS} days`) + // Unparseable input is left for a human — never rewritten blind. + assert.equal(fixRenovateConfig('not json'), 'not json') +}) + +// Glue: the tracked surfaces of THIS repo must satisfy the gate — the same +// check CI runs, exercised in-process so main() itself stays covered. +test('main --check passes against the tracked repo surfaces', () => { + assert.equal(main([]), 0) + assert.equal(main(['--quiet']), 0) +}) + +// End to end through the entrypoint guard: the CLI must resolve as main +// (realpath + file URL) and exit 0 on a clean tree. +test('CLI: node soak.mts --check --quiet exits 0', () => { + const script = fileURLToPath(new URL('./soak.mts', import.meta.url)) + const res = spawnSync(process.execPath, [script, '--check', '--quiet'], { encoding: 'utf8' }) + assert.equal(res.status, 0, res.stderr) +}) diff --git a/scripts/soak/update-deps.mts b/scripts/soak/update-deps.mts new file mode 100644 index 000000000..23e43f9b3 --- /dev/null +++ b/scripts/soak/update-deps.mts @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * @file Soaked dependency updater — every ecosystem bumps through the same + * cooldown: + * + * - npm: taze (maturityPeriod = SOAK_DAYS via the taze config next to the + * package.json) rewrites ranges, then the repo's own installer refreshes + * the lockfile. + * - cargo: `cargo update` under the pinned nightly, where + * `.cargo/config.toml` min-publish-age enforces the same window + * (too-new crate versions are skipped unless already locked). + * + * Usage: node scripts/soak/update-deps.mts [--npm|--cargo] [--dry-run] + * (no ecosystem flag = both) + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, realpathSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { NPM_INSTALLERS, NPM_PKG_DIR, REPO_ROOT, RUSTUP_CARGO } from './paths.mts' + +function run(cmd: string, args: string[], cwd: string): number { + console.log(`[update-deps] ${cmd} ${args.join(' ')} (in ${path.relative(REPO_ROOT, cwd) || '.'})`) + const res = spawnSync(cmd, args, { cwd, stdio: 'inherit' }) + if (res.error) { + console.error(`[update-deps] ${cmd}: ${res.error.message}`) + } + return res.status ?? 1 +} + +function updateNpm(dryRun: boolean): number { + const taze = path.join(NPM_PKG_DIR, 'node_modules/.bin/taze') + if (!existsSync(taze)) { + console.error(`[update-deps] taze not installed — run the installer in ${NPM_PKG_DIR} first`) + return 1 + } + // The taze config sets `write: true`; a dry run must override it + // explicitly or "dry" would still rewrite package.json. + const args = dryRun ? ['--no-write', '--no-install'] : ['--write'] + const status = run(taze, args, NPM_PKG_DIR) + if (status !== 0 || dryRun) { + return status + } + for (const [cmd, ...args] of NPM_INSTALLERS) { + if (cmd!.includes('/') && !existsSync(cmd!)) { + continue + } + return run(cmd!, args, NPM_PKG_DIR) + } + console.error('[update-deps] no installer found — refresh the lockfile manually') + return 1 +} + +function updateCargo(dryRun: boolean): number { + // The min-publish-age soak is an [unstable] cargo feature: only the + // rust-toolchain.toml nightly honors it, and only rustup's cargo shim + // reads rust-toolchain.toml. A non-rustup cargo (e.g. Homebrew stable) + // would silently update WITHOUT the soak — refuse that. + if (!existsSync(RUSTUP_CARGO)) { + console.error('[update-deps] rustup cargo shim not found — cargo update would bypass the min-publish-age soak') + return 1 + } + return run(RUSTUP_CARGO, dryRun ? ['update', '--dry-run'] : ['update'], REPO_ROOT) +} + +// No flag = both; naming both explicitly also means both — a naive +// "flag present = only that one" reading once made `--npm --cargo` run +// NEITHER, so this rule lives in one exported, regression-tested place. +export function selectEcosystems(argv: string[]): { npm: boolean; cargo: boolean } { + const npmFlag = argv.includes('--npm') + const cargoFlag = argv.includes('--cargo') + return { npm: npmFlag || !cargoFlag, cargo: cargoFlag || !npmFlag } +} + +function main(argv: string[] = process.argv.slice(2)): number { + const dryRun = argv.includes('--dry-run') + const { npm, cargo } = selectEcosystems(argv) + // Run every requested ecosystem even if an earlier one fails, then + // aggregate, so one broken ecosystem can't hide the other's drift. + const npmStatus = npm ? updateNpm(dryRun) : 0 + const cargoStatus = cargo ? updateCargo(dryRun) : 0 + return npmStatus || cargoStatus +} + +// realpath + pathToFileURL so symlinked checkouts and paths needing URL +// encoding still register as the entrypoint (ESM realpaths import.meta.url). +const isMain = + process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url +if (isMain) { + process.exitCode = main() +} diff --git a/scripts/soak/update-deps.test.mts b/scripts/soak/update-deps.test.mts new file mode 100644 index 000000000..bc159c7b9 --- /dev/null +++ b/scripts/soak/update-deps.test.mts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { selectEcosystems } from './update-deps.mts' + +test('no ecosystem flag updates both', () => { + assert.deepEqual(selectEcosystems([]), { npm: true, cargo: true }) + assert.deepEqual(selectEcosystems(['--dry-run']), { npm: true, cargo: true }) +}) + +test('a single flag selects only that ecosystem', () => { + assert.deepEqual(selectEcosystems(['--npm']), { npm: true, cargo: false }) + assert.deepEqual(selectEcosystems(['--cargo', '--dry-run']), { npm: false, cargo: true }) +}) + +test('naming both explicitly means both, not neither (regression)', () => { + assert.deepEqual(selectEcosystems(['--npm', '--cargo']), { npm: true, cargo: true }) +})