From 2c84aac32795bec93bb161c0e65ecb0a33fb1f0a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:30:20 -0500 Subject: [PATCH 01/19] ci: pin Bun 1.4.4 and Node 24.22.1 on next (CVE-2026-111497) (HACK-1758) Forward main's 69ed70e9 to next: every workflow including prerelease.yml and bun-crash-experiment.yml, .tool-versions, mise.toml, packageManager, the Dockerfiles, the candidate build guards, and the e2e and pattern fixture image tags. The pattern fixtures take the 1.4.4-alpine index digest from the registry; the same lookup reproduces the old 1.4.2 digest. mise.toml is a reviewed input of .hack/tasks.json, so its pin is updated. The raw-file method reproduced the unchanged pin first. The native file and storage helper stays on oven/bun:1.4.2-slim: its catalog pins image, Bun and libc bytes that need separate runtime qualification. Helper bytes still reproduce under Bun 1.4.4 and the helper entry reaches no Brotli, zlib or fetch path. CI pulls both images. The frozen benchmark workload pin also stays. b5d3212a needs no port: tests/helpers/env.ts already deletes unset vars. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bun-crash-experiment.yml | 4 ++-- .github/workflows/ci.yml | 17 +++++++++------- .github/workflows/prerelease.yml | 6 +++--- .../workflows/release-node-runtime-image.yml | 2 +- .github/workflows/release-prepare.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- .hack/docs/Dockerfile | 4 ++-- .hack/tasks.json | 6 +++--- .tool-versions | 4 ++-- docker/node-runtime/Dockerfile | 2 +- docker/slim-runtime/Dockerfile | 4 ++-- docs/guides/native-candidate.md | 2 +- .../.hack/docker-compose.yml | 6 +++--- .../p01-dependency-bootstrap/README.md | 2 +- .../.hack/docker-compose.yml | 2 +- .../patterns/p02-portable-toolchain/README.md | 2 +- .../.hack/docker-compose.yml | 4 ++-- .../p07-capability-overlays/README.md | 2 +- mise.toml | 4 ++-- package.json | 2 +- scripts/build-native-candidate.sh | 4 ++-- scripts/build-storage-witness-helper.ts | 4 ++-- .../generate-native-storage-witness-helper.ts | 2 +- site/README.md | 2 +- site/package.json | 2 +- tests/e2e/README.md | 20 ++++++++++--------- .../e2e/native-config-networks-acceptance.ts | 2 +- .../e2e/scenarios/dependency-cache-package.ts | 2 +- tests/e2e/scenarios/domain-migration.ts | 2 +- .../native-compose-adoption-worktrees.ts | 2 +- tests/e2e/scenarios/native-config-build.ts | 2 +- tests/e2e/scenarios/native-config-compose.ts | 2 +- .../e2e/scenarios/native-config-down-hooks.ts | 2 +- tests/e2e/scenarios/native-config-files.ts | 2 +- .../scenarios/native-config-process-policy.ts | 2 +- .../native-config-protected-files.ts | 2 +- .../native-routing-fixture-ingress.ts | 2 +- tests/e2e/scenarios/pattern-fixture.ts | 6 +++--- tests/e2e/scenarios/portable-multiservice.ts | 2 +- 39 files changed, 77 insertions(+), 72 deletions(-) diff --git a/.github/workflows/bun-crash-experiment.yml b/.github/workflows/bun-crash-experiment.yml index 6802c92b4..129f016ba 100644 --- a/.github/workflows/bun-crash-experiment.yml +++ b/.github/workflows/bun-crash-experiment.yml @@ -8,7 +8,7 @@ name: Bun crash experiment # go-ahead. # # A labeled pull request runs the matched pair on the pull request's head: the -# 1.3.9 baseline and the 1.4.2 candidate pinned on next, with the same job, +# 1.3.9 baseline and the 1.4.4 candidate pinned on next, with the same job, # budget and the default `focused` mode. A dispatch runs one Bun version at a # chosen source revision and mode. # @@ -89,7 +89,7 @@ jobs: budget="$INPUT_BUDGET_MINUTES" mode="$INPUT_MODE" else - runs='[{"bun_version":"1.3.9","role":"baseline"},{"bun_version":"1.4.2","role":"candidate"}]' + runs='[{"bun_version":"1.3.9","role":"baseline"},{"bun_version":"1.4.4","role":"candidate"}]' revision="$PR_HEAD_SHA" budget=30 mode=focused diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ad31b59f..e7a06d38c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - uses: dtolnay/rust-toolchain@1.97.1 with: components: rustfmt, clippy @@ -104,7 +104,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - uses: actions/setup-java@v4 with: distribution: temurin @@ -226,7 +226,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Setup Blacksmith Builder uses: useblacksmith/setup-docker-builder@v1 - name: Install dependencies @@ -273,7 +273,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Install tmux run: sudo apt-get update && sudo apt-get install --yes tmux - name: Install dependencies @@ -284,7 +284,10 @@ jobs: run: | docker pull alpine:3.20 docker pull alpine:3.22 - docker pull node:24.11.0-bookworm-slim + docker pull node:24.22.1-bookworm-slim + docker pull oven/bun:1.4.4-slim + # The qualified native file and storage helper artifact stays on this image + # until its catalog pins are requalified (src/lib/native-compose-vm-file-protocol.ts). docker pull oven/bun:1.4.2-slim docker pull postgres:17.6-alpine docker pull lucaslorentz/caddy-docker-proxy:2.10.0-alpine @@ -382,7 +385,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Install dependencies run: bun install --frozen-lockfile # The one-off checks and builds run once, on shard 1. @@ -449,7 +452,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Require native regression tools run: | command -v python3 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index c20a17999..8ef247c4f 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -47,7 +47,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Validate immutable plan id: plan run: bun scripts/prerelease-plan.ts plan @@ -74,7 +74,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - uses: dtolnay/rust-toolchain@1.97.1 with: targets: aarch64-unknown-linux-musl @@ -122,7 +122,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - uses: actions/download-artifact@v4 with: name: v5-prerelease-${{ needs.plan.outputs.version }} diff --git a/.github/workflows/release-node-runtime-image.yml b/.github/workflows/release-node-runtime-image.yml index 21f926655..8ad50fa04 100644 --- a/.github/workflows/release-node-runtime-image.yml +++ b/.github/workflows/release-node-runtime-image.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Setup QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index ba5001c0e..3b4cc04f8 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -28,12 +28,12 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "24.11.0" + node-version: "24.22.1" - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Install dependencies run: bun install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72e27b3d3..8b553ec86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Resolve version id: version env: @@ -108,7 +108,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Install dependencies run: bun install --frozen-lockfile - uses: dtolnay/rust-toolchain@1.97.1 @@ -209,7 +209,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.4.2" + bun-version: "1.4.4" - name: Render formula env: GH_TOKEN: ${{ github.token }} diff --git a/.hack/docs/Dockerfile b/.hack/docs/Dockerfile index c167d0212..db5493989 100644 --- a/.hack/docs/Dockerfile +++ b/.hack/docs/Dockerfile @@ -1,5 +1,5 @@ -FROM oven/bun:1.4.2 AS bun -FROM node:24.11.0-bookworm-slim +FROM oven/bun:1.4.4 AS bun +FROM node:24.22.1-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun diff --git a/.hack/tasks.json b/.hack/tasks.json index 9b79f72d1..0cf5d7bb5 100644 --- a/.hack/tasks.json +++ b/.hack/tasks.json @@ -42,7 +42,7 @@ }, { "path": "mise.toml", - "sha256": "e43a4542f0c4a466219402a1f5b9ad993bf4a6b109cadfba9281149de9a2f205" + "sha256": "e5f44e5efc9072c0beaf92277227e9729b65a12366173dfdb01557ee078670b1" }, { "path": "package.json", @@ -105,7 +105,7 @@ }, { "path": "mise.toml", - "sha256": "e43a4542f0c4a466219402a1f5b9ad993bf4a6b109cadfba9281149de9a2f205" + "sha256": "e5f44e5efc9072c0beaf92277227e9729b65a12366173dfdb01557ee078670b1" }, { "path": "package.json", @@ -162,7 +162,7 @@ }, { "path": "mise.toml", - "sha256": "e43a4542f0c4a466219402a1f5b9ad993bf4a6b109cadfba9281149de9a2f205" + "sha256": "e5f44e5efc9072c0beaf92277227e9729b65a12366173dfdb01557ee078670b1" } ], "review": "HACK-1157" diff --git a/.tool-versions b/.tool-versions index e7521662c..d267d896b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -bun 1.4.2 -nodejs 24.11.0 +bun 1.4.4 +nodejs 24.22.1 zig 0.15.2 diff --git a/docker/node-runtime/Dockerfile b/docker/node-runtime/Dockerfile index 58eca2a73..d588d3caf 100644 --- a/docker/node-runtime/Dockerfile +++ b/docker/node-runtime/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM oven/bun:1.4.2 AS hack-build +FROM oven/bun:1.4.4 AS hack-build WORKDIR /src COPY package.json bun.lock bunfig.toml tsconfig.json index.ts ./ diff --git a/docker/slim-runtime/Dockerfile b/docker/slim-runtime/Dockerfile index 2b8b17cc8..7a0a9b174 100644 --- a/docker/slim-runtime/Dockerfile +++ b/docker/slim-runtime/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM oven/bun:1.4.2 AS hack-build +FROM oven/bun:1.4.4 AS hack-build WORKDIR /src COPY package.json bun.lock bunfig.toml tsconfig.json index.ts ./ @@ -10,7 +10,7 @@ COPY src ./src RUN bun install --frozen-lockfile RUN bun build index.ts --compile --outfile /out/hack -FROM oven/bun:1.4.2 +FROM oven/bun:1.4.4 ARG HACK_VERSION=dev ARG BUILD_DATE=unknown diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 5110eeca2..6109d8288 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -6,7 +6,7 @@ projects, or install DNS or trust automatically. Its graph commands support a bounded subset; this bundle is not application parity or release qualification. -Build with the repository's pinned Bun 1.4.2, Rust 1.97.1 and Zig 0.15.2 toolchain, Python 3, +Build with the repository's pinned Bun 1.4.4, Rust 1.97.1 and Zig 0.15.2 toolchain, Python 3, and the Rust `aarch64-unknown-linux-musl` standard library already installed. The build requires a clean checkout and refuses missing prerequisites; it does not install toolchains: diff --git a/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml b/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml index 7ab4a4cdd..9c78d7a9b 100644 --- a/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml +++ b/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml @@ -2,7 +2,7 @@ name: pattern-p01 services: # One-shot initializer for the immutable dependency payload (HACK-P01). install: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a platform: linux/arm64 working_dir: /app entrypoint: [] @@ -20,7 +20,7 @@ services: # Worktree-specific generation: always runs, owns its own output volume. generate: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a platform: linux/arm64 working_dir: /app command: ["bun", "scripts/generate.ts"] @@ -33,7 +33,7 @@ services: - generated:/generated app: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a platform: linux/arm64 working_dir: /app command: ["bun", "src/server.ts"] diff --git a/examples/patterns/p01-dependency-bootstrap/README.md b/examples/patterns/p01-dependency-bootstrap/README.md index c6f9e9396..8505809b2 100644 --- a/examples/patterns/p01-dependency-bootstrap/README.md +++ b/examples/patterns/p01-dependency-bootstrap/README.md @@ -71,5 +71,5 @@ a warm start, a second worktree, a `bun.lock` change, and an installer killed mid-run. Every running consumer must read a complete old or complete new payload. The killed attempt must stay unready, a retry under the same generation must be refused, and a generation bump must recover. The copy uses -the runner's cached `oven/bun:1.4.2-slim` digest and the daemon's platform +the runner's cached `oven/bun:1.4.4-slim` digest and the daemon's platform instead of the pinned Alpine image, and never pulls. diff --git a/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml b/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml index 2012afece..f72c2822b 100644 --- a/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml +++ b/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml @@ -3,7 +3,7 @@ services: # One-shot toolchain behind an opt-in profile (HACK-P02). There is no # long-running application service in this project. toolchain: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a profiles: [toolchain] init: true working_dir: /app diff --git a/examples/patterns/p02-portable-toolchain/README.md b/examples/patterns/p02-portable-toolchain/README.md index 2be5dcef5..b5038f3e4 100644 --- a/examples/patterns/p02-portable-toolchain/README.md +++ b/examples/patterns/p02-portable-toolchain/README.md @@ -73,5 +73,5 @@ whose target is not mounted, and the checks still pass because they do not need Git. A failing check exits non-zero, Hack returns the command's exact exit status, and an ordinary down keeps both instances' cache volumes. Each instance gets its own caches; cross-worktree reuse is still open. The copy uses the runner's -cached `oven/bun:1.4.2-slim` digest instead of the pinned Alpine image, and +cached `oven/bun:1.4.4-slim` digest instead of the pinned Alpine image, and never pulls. diff --git a/examples/patterns/p07-capability-overlays/.hack/docker-compose.yml b/examples/patterns/p07-capability-overlays/.hack/docker-compose.yml index b10e4c0d4..ba5861498 100644 --- a/examples/patterns/p07-capability-overlays/.hack/docker-compose.yml +++ b/examples/patterns/p07-capability-overlays/.hack/docker-compose.yml @@ -3,7 +3,7 @@ name: pattern-p07 # overlays change only auth and helper capabilities, never these services. services: upstream: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a working_dir: /app command: ["bun", "src/upstream.ts"] volumes: @@ -15,7 +15,7 @@ services: retries: 30 api: - image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + image: oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a working_dir: /app command: ["bun", "src/api.ts"] depends_on: diff --git a/examples/patterns/p07-capability-overlays/README.md b/examples/patterns/p07-capability-overlays/README.md index 7c7c25c96..bef988672 100644 --- a/examples/patterns/p07-capability-overlays/README.md +++ b/examples/patterns/p07-capability-overlays/README.md @@ -77,5 +77,5 @@ names each source file without printing values, including an inherited primary-local runner override. It also checks that the containers' metadata carries each overlay's keys, that both APIs reach `upstream`, and that no Hack command tries to open a browser. The copy uses the runner's cached -`oven/bun:1.4.2-slim` digest instead of the pinned Alpine image, and never +`oven/bun:1.4.4-slim` digest instead of the pinned Alpine image, and never pulls. diff --git a/mise.toml b/mise.toml index 805b4e6ca..b2eb73472 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] -bun = "1.4.2" -node = "24.11.0" +bun = "1.4.4" +node = "24.22.1" zig = "0.15.2" rust = "1.97.1" java = "temurin-17.0.20+101" diff --git a/package.json b/package.json index e4932e9e0..f01900bf5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hack-cli", "version": "4.2.1", - "packageManager": "bun@1.4.2", + "packageManager": "bun@1.4.4", "module": "index.ts", "type": "module", "workspaces": [ diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 25865acb6..e947d7504 100755 --- a/scripts/build-native-candidate.sh +++ b/scripts/build-native-candidate.sh @@ -40,8 +40,8 @@ if [ "$(zig version)" != 0.15.2 ]; then exit 69 fi command -v python3 >/dev/null -if [ "$(bun --version)" != 1.4.2 ]; then - echo "Pinned Bun 1.4.2 is required" >&2 +if [ "$(bun --version)" != 1.4.4 ]; then + echo "Pinned Bun 1.4.4 is required" >&2 exit 69 fi stdlib=$(rustc --print target-libdir --target aarch64-unknown-linux-musl) diff --git a/scripts/build-storage-witness-helper.ts b/scripts/build-storage-witness-helper.ts index 6ee097aad..0bc6e34c3 100644 --- a/scripts/build-storage-witness-helper.ts +++ b/scripts/build-storage-witness-helper.ts @@ -19,7 +19,7 @@ export async function buildStorageWitnessHelper(opts: { readonly outputDirectory: string; }): Promise { const directory = opts.outputDirectory; - if (!isAbsolute(directory) || Bun.version !== "1.4.2") { + if (!isAbsolute(directory) || Bun.version !== "1.4.4") { throw new Error( "Pinned Bun and an absolute exclusive output directory are required." ); @@ -67,7 +67,7 @@ export async function buildStorageWitnessHelper(opts: { typeof packageJson === "object" && packageJson !== null && "packageManager" in packageJson && - packageJson.packageManager === "bun@1.4.2" + packageJson.packageManager === "bun@1.4.4" ) ) { throw new Error("Helper source Bun pin refused."); diff --git a/scripts/generate-native-storage-witness-helper.ts b/scripts/generate-native-storage-witness-helper.ts index da464da14..84c53688f 100644 --- a/scripts/generate-native-storage-witness-helper.ts +++ b/scripts/generate-native-storage-witness-helper.ts @@ -14,7 +14,7 @@ let group = 0, started = false; try { if ( - Bun.version !== "1.4.2" || + Bun.version !== "1.4.4" || !["--check", "--write"].includes(process.argv[2] ?? "") ) { throw new Error("Pinned helper generation refused"); diff --git a/site/README.md b/site/README.md index b398e7476..43cc85094 100644 --- a/site/README.md +++ b/site/README.md @@ -24,7 +24,7 @@ The service is capped at two CPUs and 6 GB memory, with a 3 GB Node heap. Restart the service after source changes to rebuild the preview. This avoids development-server compilation and HMR overhead while reviewing the whole corpus. -Use the repository's pinned Bun 1.4.2. Site dependencies and `site/bun.lock` are +Use the repository's pinned Bun 1.4.4. Site dependencies and `site/bun.lock` are independent of the CLI workspace; do not change the root Bun pin for Next.js. Next.js also requires a supported Node.js installation (20.9 or newer). diff --git a/site/package.json b/site/package.json index c120fc40d..1cd56afe0 100644 --- a/site/package.json +++ b/site/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "packageManager": "bun@1.4.2", + "packageManager": "bun@1.4.4", "scripts": { "dev": "next dev --hostname 127.0.0.1", "build": "next build --webpack", diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 456d8a31f..3019b8ba0 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -136,7 +136,7 @@ define those two spellings. The explicitly selected `native-config-vm-files` scenario exercises ordinary new containers with protected VM-native material; it does not adopt existing binds. -It uses only cached `oven/bun:1.4.2-slim`, two linked worktrees, no published ports +It uses only the cached helper image `oven/bun:1.4.2-slim`, two linked worktrees, no published ports and no image build or pull. Application-side checks independently require exact 0400/default-root and 0600/explicit-numeric owner identity, actual owner reads, distinct-UID `EACCES`, privileged write-open `EROFS`, exact single-file mounts and @@ -171,7 +171,7 @@ records compiled-CLI volume readback and cleanup against an isolated engine. The `dependency-cache`, `dependency-cache-protocol`, and `dependency-cache-package` Docker scenarios use standalone, network-disabled containers and exact ownership labels for cleanup. Prepare `alpine:3.20`, -`alpine:3.22`, and `node:24.11.0-bookworm-slim` locally first; CI pulls these before +`alpine:3.22`, and `node:24.22.1-bookworm-slim` locally first; CI pulls these before the scenarios. Tests resolve installed repository digests and refuse missing prerequisites rather than downloading during qualification. The package scenario packs a local npm package and installs its real lockfile offline; it does not test @@ -189,7 +189,7 @@ platform identity, not cross-architecture package compatibility. `portable-multiservice` exercises a Bun HTTP app and PostgreSQL through the selected Hack CLI in slim mode. It needs Docker with Compose and locally cached -`oven/bun:1.4.2-slim` and `postgres:17.6-alpine`; image tags are resolved to immutable +`oven/bun:1.4.4-slim` and `postgres:17.6-alpine`; image tags are resolved to immutable IDs before startup, and the scenario never pulls. Missing prerequisites fail. It uses a private internal Compose network, no published ports or `hack-dev`, and explicitly disables Hack internal DNS/TLS. No credentials or global setup are needed. @@ -273,7 +273,7 @@ checked-in `examples/patterns/` fixtures against a real engine. Each catalogue c names its scenario in `e2eScenario`, and `tests/pattern-examples.test.ts` fails when that scenario is not registered here as Docker-tier with a duration hint. `tests/e2e/scenarios/pattern-fixture.ts` makes the copy. It changes only the -project name, the image (the cached `oven/bun:1.4.2-slim` repository digest with +project name, the image (the cached `oven/bun:1.4.4-slim` repository digest with `pull_policy: never` instead of the pinned Alpine image), a declared platform (the daemon's architecture) and exact owner labels. Commands, mounts, dependency labels, gates and health checks stay as the fixture authored them. @@ -396,7 +396,9 @@ HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tes ## Native file qualification `native-config-files` requires the current compiled CLI, its matching compiler and -cached `oven/bun:1.4.2-slim`. It pins the existing image, uses no builds or published +cached `oven/bun:1.4.4-slim` (the workload image) plus `oven/bun:1.4.2-slim` (the qualified +native file and storage helper image, which stays on Bun 1.4.2 until its catalog +pins are requalified). It pins the existing image, uses no builds or published ports, and does not pull or activate DNS/trust. The primary and two linked worktrees deliver distinct binary configs, encrypted managed secrets and empty files through literal dollar-containing targets and a private runtime home outside the checkout. @@ -473,7 +475,7 @@ fixtures or admit file/build/job/health/custom-network intersections. ## Native config process-policy qualification `native-config-process-policy` is registered in the optional `docker-e2e` CI job. It needs the -current compiled CLI, matching compiler and cached `oven/bun:1.4.2-slim`; it resolves +current compiled CLI, matching compiler and cached `oven/bun:1.4.4-slim`; it resolves the immutable image ID and never pulls, publishes ports, or activates DNS/trust. ```sh @@ -530,7 +532,7 @@ exposes this check as mode `settled`. ## Native config routing qualification `native-config-routing` is an optional `docker-e2e` CI scenario using the current compiled -CLI and matching config compiler. Prepare `oven/bun:1.4.2-slim` and +CLI and matching config compiler. Prepare `oven/bun:1.4.4-slim` and `lucaslorentz/caddy-docker-proxy:2.10.0-alpine` locally first; the scenario resolves cached immutable image IDs and never pulls. It requires the existing `hack-dev` network and refuses any running global Caddy selector before creating its fixture. @@ -580,7 +582,7 @@ excluded from the default local/Docker suite. Select it explicitly with Docker skip enforcement remains unchanged for the portable Docker tier. This scenario requires `HACK_E2E_DOMAIN_ROUTING=1`, -a locally installed `node:24.11.0-bookworm-slim` image, existing Caddy ingress on +a locally installed `node:24.22.1-bookworm-slim` image, existing Caddy ingress on `hack-dev`, and its exported public CA (default `~/.hack/caddy/pki/caddy-local-authority.crt`, override with `HACK_E2E_DOMAIN_CA`). It makes no global DNS or trust changes. Build the current host CLI first with `bun run build`. @@ -761,7 +763,7 @@ control, an explicit `setup agents --agents-md`, must change exactly ## Native build acceptance `native-config-build` is an optional `docker-e2e` CI scenario using the cached -`oven/bun:1.4.2-slim` base and the current compiled CLI/compiler. Its native-only +`oven/bun:1.4.4-slim` base and the current compiled CLI/compiler. Its native-only project builds a selected multistage target through a checkout-anchored context with literal dollar characters and a context-relative nested Dockerfile. A second job qualifies the default Dockerfile. Actual profile/argv, completed-job readiness, diff --git a/tests/e2e/native-config-networks-acceptance.ts b/tests/e2e/native-config-networks-acceptance.ts index ec8358b56..0a52f451c 100644 --- a/tests/e2e/native-config-networks-acceptance.ts +++ b/tests/e2e/native-config-networks-acceptance.ts @@ -79,7 +79,7 @@ export function nativeNetworkFixtureCommandTimeout(opts: { ); return COMMAND_TIMEOUT; } -const BUN_TAG = "oven/bun:1.4.2-slim"; +const BUN_TAG = "oven/bun:1.4.4-slim"; const CADDY_TAG = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; const PRIVATE_TMPFS = "rw,nosuid,nodev,noexec,size=64m,mode=0700"; diff --git a/tests/e2e/scenarios/dependency-cache-package.ts b/tests/e2e/scenarios/dependency-cache-package.ts index e01f2909e..d719316d0 100644 --- a/tests/e2e/scenarios/dependency-cache-package.ts +++ b/tests/e2e/scenarios/dependency-cache-package.ts @@ -18,7 +18,7 @@ import { const OWNER = "hack.e2e.cache-package-owner"; const BRANCH = "package-linked"; const TIMEOUT_MS = 60_000; -const IMAGE = "node:24.11.0-bookworm-slim"; +const IMAGE = "node:24.22.1-bookworm-slim"; const PACKAGE_VALUE = "installed-local-package-v1"; const INSTALL = `set -eu echo install >> /deps/install-count diff --git a/tests/e2e/scenarios/domain-migration.ts b/tests/e2e/scenarios/domain-migration.ts index 9d076a316..04db7197b 100644 --- a/tests/e2e/scenarios/domain-migration.ts +++ b/tests/e2e/scenarios/domain-migration.ts @@ -5,7 +5,7 @@ import { expect, expectExit, runCommand, type Scenario } from "../harness.ts"; const OWNER = "hack.e2e.domain-owner"; const BRANCH = "domain-proof"; -const IMAGE = "node:24.11.0-bookworm-slim"; +const IMAGE = "node:24.22.1-bookworm-slim"; const TIMEOUT = 60_000; /** Explicit opt-in: uses existing ingress, never installs DNS or trust. Native DNS qualification is separate. */ diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index c0bc1c5a7..441be3b1b 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -1015,7 +1015,7 @@ export async function prepareFixtureInputs( ? await probe([ "image", "inspect", - "oven/bun:1.4.2-slim", + "oven/bun:1.4.4-slim", "--format", "{{.Id}}", ]) diff --git a/tests/e2e/scenarios/native-config-build.ts b/tests/e2e/scenarios/native-config-build.ts index 6b5fe784b..bbd1547aa 100644 --- a/tests/e2e/scenarios/native-config-build.ts +++ b/tests/e2e/scenarios/native-config-build.ts @@ -48,7 +48,7 @@ const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const RESOURCE_ID = /^[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; const ENGINE_ID = /^[A-Za-z0-9:-]{1,128}$/; -const BASE_TAG = "oven/bun:1.4.2-slim"; +const BASE_TAG = "oven/bun:1.4.4-slim"; const IMAGE_OWNER = "hack.e2e.native-build.owner"; const IMAGE_PROJECT = "hack.e2e.native-build.project"; const IMAGE_AMBIENT = "hack.e2e.native-build.ambient"; diff --git a/tests/e2e/scenarios/native-config-compose.ts b/tests/e2e/scenarios/native-config-compose.ts index 5c784551a..a924706f1 100644 --- a/tests/e2e/scenarios/native-config-compose.ts +++ b/tests/e2e/scenarios/native-config-compose.ts @@ -348,7 +348,7 @@ export const nativeConfigComposeScenario: Scenario = { }); return id; }; - const bunImage = await image("oven/bun:1.4.2-slim"); + const bunImage = await image("oven/bun:1.4.4-slim"); const postgresImage = await image("postgres:17.6-alpine"); const created = await createMonorepoFixture({ parentDir: ctx.tempRoot, diff --git a/tests/e2e/scenarios/native-config-down-hooks.ts b/tests/e2e/scenarios/native-config-down-hooks.ts index 2ae8bbdf8..d2d905156 100644 --- a/tests/e2e/scenarios/native-config-down-hooks.ts +++ b/tests/e2e/scenarios/native-config-down-hooks.ts @@ -66,7 +66,7 @@ async function setup(ctx: ScenarioContext) { await docker([ "image", "inspect", - "oven/bun:1.4.2-slim", + "oven/bun:1.4.4-slim", "--format", "{{.Id}}", ]) diff --git a/tests/e2e/scenarios/native-config-files.ts b/tests/e2e/scenarios/native-config-files.ts index 980eae0a8..0a8ec4601 100644 --- a/tests/e2e/scenarios/native-config-files.ts +++ b/tests/e2e/scenarios/native-config-files.ts @@ -263,7 +263,7 @@ async function setup(ctx: ScenarioContext) { const image = await docker([ "image", "inspect", - "oven/bun:1.4.2-slim", + "oven/bun:1.4.4-slim", "--format", "{{.Id}}", ]); diff --git a/tests/e2e/scenarios/native-config-process-policy.ts b/tests/e2e/scenarios/native-config-process-policy.ts index 0b240c595..c62e30f7b 100644 --- a/tests/e2e/scenarios/native-config-process-policy.ts +++ b/tests/e2e/scenarios/native-config-process-policy.ts @@ -487,7 +487,7 @@ export const nativeConfigProcessPolicyScenario: Scenario = { const image = await docker([ "image", "inspect", - "oven/bun:1.4.2-slim", + "oven/bun:1.4.4-slim", "--format", "{{.Id}}", ]); diff --git a/tests/e2e/scenarios/native-config-protected-files.ts b/tests/e2e/scenarios/native-config-protected-files.ts index 586a8d84d..cfeaad007 100644 --- a/tests/e2e/scenarios/native-config-protected-files.ts +++ b/tests/e2e/scenarios/native-config-protected-files.ts @@ -46,7 +46,7 @@ const ID = /^[a-f0-9]{64}$/; const HASH = /^[a-f0-9]{64}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; const PROJECT = "com.docker.compose.project"; -const BUN_TAG = "oven/bun:1.4.2-slim"; +const BUN_TAG = "oven/bun:1.4.4-slim"; const DB_TAG = "postgres:17.6-alpine"; const LOOP = ["bun", "-e", "setInterval(()=>{},1000)"]; const FORMATS = { diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts index 4cf54c631..81248c635 100644 --- a/tests/e2e/scenarios/native-routing-fixture-ingress.ts +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -207,7 +207,7 @@ export async function prepareNativeRoutingFixtureIngress(opts: { }); return id; }; - const bunImage = await image("oven/bun:1.4.2-slim"); + const bunImage = await image("oven/bun:1.4.4-slim"); const caddyImage = await image(CADDY_IMAGE); const token = randomBytes(16).toString("hex"); const proxyName = `e2e-native-routing-proxy-${token}`; diff --git a/tests/e2e/scenarios/pattern-fixture.ts b/tests/e2e/scenarios/pattern-fixture.ts index 6f7b42732..04dc5c0d4 100644 --- a/tests/e2e/scenarios/pattern-fixture.ts +++ b/tests/e2e/scenarios/pattern-fixture.ts @@ -26,7 +26,7 @@ import { * disposable, offline CI run needs: * - the project name and dev host become a random `e2e-` name; * - the digest-pinned Alpine Bun image becomes the repository digest of the - * runner's cached `oven/bun:1.4.2-slim` (same Bun release), with + * runner's cached `oven/bun:1.4.4-slim` (same Bun release), with * `pull_policy: never`, so nothing is downloaded during qualification; * - a declared `platform` becomes the daemon's architecture; * - every service and named volume gets an exact owner label for cleanup. @@ -37,9 +37,9 @@ import { /** The image every pattern fixture declares today. */ export const PATTERN_FIXTURE_IMAGE = - "oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f"; + "oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a"; /** Pre-pulled by the docker-e2e job; resolved to its repository digest. */ -export const CACHED_BUN_IMAGE = "oven/bun:1.4.2-slim"; +export const CACHED_BUN_IMAGE = "oven/bun:1.4.4-slim"; const DOCKER_TIMEOUT_MS = 60_000; const ARCHITECTURES: Readonly> = { diff --git a/tests/e2e/scenarios/portable-multiservice.ts b/tests/e2e/scenarios/portable-multiservice.ts index f8aafd4f0..19ee45bbb 100644 --- a/tests/e2e/scenarios/portable-multiservice.ts +++ b/tests/e2e/scenarios/portable-multiservice.ts @@ -68,7 +68,7 @@ export const portableMultiserviceScenario: Scenario = { ctx.log(`${tag}: ${id}`); return id; }; - const bunImage = await image("oven/bun:1.4.2-slim"); + const bunImage = await image("oven/bun:1.4.4-slim"); const postgresImage = await image("postgres:17.6-alpine"); const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, From dd92fae0ff7bc22677ff6d933c0fc2bc68277611 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:35:15 -0500 Subject: [PATCH 02/19] fix(deps): forward zod, next, MCP SDK and drizzle-orm security bumps (HACK-1758) Forward main's 739c4e57 and 75225e00 where next was below main: - zod 4.3.5 to 4.6.7 (GHSA-mrhx-c7xc-m3w5) - @modelcontextprotocol/sdk 1.26.0 to 1.32.0 - drizzle-orm ^0.44.5 to 0.45.2 in packages/db - next 16.2.6 to 16.4.1 in examples/next-app site/ already pins next 16.4.3 and zod 4.6.7. Both lockfiles are regenerated. examples/next-app/bun.lock had still resolved next 16.1.1. drizzle-orm 0.45 no longer auto-installs its prisma peer, so the prisma tree leaves the root lockfile, as it already has on main. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 176 ++------------------------------- examples/next-app/bun.lock | 94 ++++++++++-------- examples/next-app/package.json | 2 +- package.json | 4 +- packages/db/package.json | 2 +- 5 files changed, 66 insertions(+), 212 deletions(-) diff --git a/bun.lock b/bun.lock index cf47220fc..b876c9e2a 100644 --- a/bun.lock +++ b/bun.lock @@ -8,13 +8,13 @@ "@balena/dockerignore": "1.0.2", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", - "@modelcontextprotocol/sdk": "1.26.0", + "@modelcontextprotocol/sdk": "1.32.0", "@opentui/core": "^0.1.74", "elysia": "^1.4.9", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "yaml": "2.8.2", - "zod": "4.3.5", + "zod": "4.6.7", }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -43,14 +43,14 @@ }, "packages/cli": { "name": "@hack/cli", - "version": "3.5.2", + "version": "4.2.1", }, "packages/db": { "name": "@hack/db", - "version": "3.5.2", + "version": "4.2.1", "dependencies": { "@neondatabase/serverless": "^1.0.1", - "drizzle-orm": "^0.44.5", + "drizzle-orm": "0.45.2", "zod": "^4.1.12", }, "devDependencies": { @@ -61,8 +61,6 @@ }, }, "packages": { - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - "@actions/core": ["@actions/core@2.0.1", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.0" } }, "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg=="], "@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], @@ -97,14 +95,6 @@ "@charmland/lipgloss": ["@charmland/lipgloss@2.0.0-beta.3-0e280f3", "", {}, "sha512-F2J7CCl6srSbtlIzJEELtWCq3yVgTQ9pi9JYqR8pcss45as9xGQQdMzJ2QbcKH+Lv8IbAbmtT5ryE4thjGUbDA=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], - - "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], - - "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], - - "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], - "@clack/core": ["@clack/core@1.0.0-alpha.7", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-3vdh6Ar09D14rVxJZIm3VQJkU+ZOKKT5I5cC0cOVazy70CNyYYjiwRj9unwalhESndgxx6bGc/m6Hhs4EKF5XQ=="], "@clack/prompts": ["@clack/prompts@1.0.0-alpha.9", "", { "dependencies": { "@clack/core": "1.0.0-alpha.7", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-sKs0UjiHFWvry4SiRfBi5Qnj0C/6AYx8aKkFPZQSuUZXgAram25ZDmhQmP7vj1aFyLpfHWtLQjWvOvcat0TOLg=="], @@ -149,12 +139,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], - - "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], - - "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], - "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -279,9 +263,7 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], - - "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA=="], "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], @@ -327,28 +309,6 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@2.3.1", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw=="], - "@prisma/client": ["@prisma/client@7.4.2", "", { "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA=="], - - "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.4.2", "", {}, "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q=="], - - "@prisma/config": ["@prisma/config@7.4.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg=="], - - "@prisma/debug": ["@prisma/debug@7.4.2", "", {}, "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w=="], - - "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], - - "@prisma/engines": ["@prisma/engines@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "7.4.2", "@prisma/get-platform": "7.4.2" } }, "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA=="], - - "@prisma/engines-version": ["@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "", {}, "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg=="], - - "@prisma/fetch-engine": ["@prisma/fetch-engine@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/get-platform": "7.4.2" } }, "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA=="], - - "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], - - "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], - - "@prisma/studio-core": ["@prisma/studio-core@0.13.1", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg=="], - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@semantic-release/changelog": ["@semantic-release/changelog@6.0.3", "", { "dependencies": { "@semantic-release/error": "^3.0.0", "aggregate-error": "^3.0.0", "fs-extra": "^11.0.0", "lodash": "^4.17.4" }, "peerDependencies": { "semantic-release": ">=18.0.0" } }, "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag=="], @@ -373,8 +333,6 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="], @@ -391,8 +349,6 @@ "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], @@ -429,8 +385,6 @@ "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -469,8 +423,6 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], - "cachedir": ["cachedir@2.3.0", "", {}, "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -487,10 +439,6 @@ "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], - "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], - - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], @@ -521,12 +469,8 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -561,8 +505,6 @@ "crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="], "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], @@ -577,18 +519,10 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - - "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], - "detect-file": ["detect-file@1.0.0", "", {}, "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -601,11 +535,9 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], - "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], - "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -613,16 +545,12 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], - "elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], - "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], @@ -671,12 +599,8 @@ "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], - "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -707,8 +631,6 @@ "findup-sync": ["findup-sync@4.0.0", "", { "dependencies": { "detect-file": "^1.0.0", "is-glob": "^4.0.0", "micromatch": "^4.0.2", "resolve-dir": "^1.0.1" } }, "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ=="], - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -723,16 +645,12 @@ "function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="], - "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], @@ -741,8 +659,6 @@ "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], @@ -759,10 +675,6 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], - - "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -785,8 +697,6 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -841,8 +751,6 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-text-path": ["is-text-path@2.0.0", "", { "dependencies": { "text-extensions": "^2.0.0" } }, "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw=="], @@ -887,10 +795,6 @@ "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], - "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], - - "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], @@ -931,14 +835,10 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "longest": ["longest@2.0.1", "", {}, "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q=="], "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], - "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "make-asynchronous": ["make-asynchronous@1.0.1", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "1.2.0" } }, "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ=="], "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -979,12 +879,8 @@ "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], - "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -993,8 +889,6 @@ "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], - "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "normalize-url": ["normalize-url@8.1.1", "", {}, "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="], @@ -1009,8 +903,6 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1083,8 +975,6 @@ "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], @@ -1103,14 +993,10 @@ "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="], - "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], - "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], - "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], - "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], @@ -1125,20 +1011,14 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], - "prisma": ["prisma@7.4.2", "", { "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", "@prisma/engines": "7.4.2", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], @@ -1151,12 +1031,6 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -1165,14 +1039,8 @@ "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], - "registry-auth-token": ["registry-auth-token@5.1.0", "", { "dependencies": { "@pnpm/npm-conf": "^2.1.0" } }, "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw=="], - "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1187,8 +1055,6 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], @@ -1201,8 +1067,6 @@ "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semantic-release": ["semantic-release@25.0.2", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.1.1", "@semantic-release/release-notes-generator": "^14.1.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^9.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^12.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^18.0.0" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1213,8 +1077,6 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -1259,14 +1121,10 @@ "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], - "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], - "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "stream-combiner2": ["stream-combiner2@1.1.1", "", { "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1377,8 +1235,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], - "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1411,6 +1267,8 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1421,9 +1279,7 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], - - "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -1489,14 +1345,6 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], - - "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], - - "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], - - "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], - "@semantic-release/exec/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], "@semantic-release/git/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1537,8 +1385,6 @@ "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], - "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -1893,8 +1739,6 @@ "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], - "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "rc/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/examples/next-app/bun.lock b/examples/next-app/bun.lock index b7fe6bcb3..25841ccde 100644 --- a/examples/next-app/bun.lock +++ b/examples/next-app/bun.lock @@ -5,7 +5,7 @@ "": { "name": "bun-app", "dependencies": { - "next": "16.1.1", + "next": "16.4.1", "react": "19.2.3", "react-dom": "19.2.3", "xterm": "^5.3.0", @@ -64,7 +64,7 @@ "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], @@ -94,55 +94,59 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -156,25 +160,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@next/env": ["@next/env@16.1.1", "", {}, "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA=="], + "@next/env": ["@next/env@16.4.1", "", {}, "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.1.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.1", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.1", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.1", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.1", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -186,7 +190,7 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -330,7 +334,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -658,7 +662,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.1.1", "", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="], + "next": ["next@16.4.1", "", { "dependencies": { "@next/env": "16.4.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.1", "@next/swc-darwin-x64": "16.4.1", "@next/swc-linux-arm64-gnu": "16.4.1", "@next/swc-linux-arm64-musl": "16.4.1", "@next/swc-linux-x64-gnu": "16.4.1", "@next/swc-linux-x64-musl": "16.4.1", "@next/swc-win32-arm64-msvc": "16.4.1", "@next/swc-win32-x64-msvc": "16.4.1", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -746,7 +750,7 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -856,6 +860,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -874,6 +880,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -888,10 +896,12 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], - "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "sharp/semver": ["semver@7.8.6", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "next/postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], } } diff --git a/examples/next-app/package.json b/examples/next-app/package.json index d7ac1f4dd..95c1025ea 100644 --- a/examples/next-app/package.json +++ b/examples/next-app/package.json @@ -10,7 +10,7 @@ "ops": "bun run scripts/ops.ts" }, "dependencies": { - "next": "16.2.6", + "next": "16.4.1", "react": "19.2.3", "react-dom": "19.2.3", "xterm": "^5.3.0", diff --git a/package.json b/package.json index f01900bf5..7105bf31d 100644 --- a/package.json +++ b/package.json @@ -118,11 +118,11 @@ "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "elysia": "^1.4.9", - "@modelcontextprotocol/sdk": "1.26.0", + "@modelcontextprotocol/sdk": "1.32.0", "@opentui/core": "^0.1.74", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "yaml": "2.8.2", - "zod": "4.3.5" + "zod": "4.6.7" } } diff --git a/packages/db/package.json b/packages/db/package.json index e7f9cdd33..2481a62b0 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@neondatabase/serverless": "^1.0.1", - "drizzle-orm": "^0.44.5", + "drizzle-orm": "0.45.2", "zod": "^4.1.12" }, "devDependencies": { From 80abd5fcf0cb83bafdcad5b1ad6cb2fd00ecddf0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:59:44 -0500 Subject: [PATCH 03/19] fix: make keychain tests hermetic on next (HACK-1758) Forward main's 4c194a12. Native keychain access goes through one gated boundary (src/lib/keychain.ts), so HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK stops every read, write and delete, and tests/preload.ts replaces Bun.secrets with an in-memory backend for the whole `bun test` process. Adapted for next: the preload runs before the state guards, so their baseline holds the flag. The fake is not frozen because next's tests spy on it. Tests that use a stubbed keychain open the gate explicitly and restore it with restoreEnv. Co-Authored-By: Claude Opus 5.5 --- bunfig.toml | 3 + docs/env.md | 14 ++ src/commands/secrets.ts | 9 +- .../extensions/tailscale/commands.ts | 8 +- src/lib/keychain.ts | 54 ++++++ src/lib/nodes-registry.ts | 181 ++++++++++-------- src/lib/secret-store.ts | 46 ++--- tests/env-backend-command.test.ts | 5 +- tests/env-get.test.ts | 4 + tests/keychain-guard.test.ts | 89 +++++++++ tests/no-tty-prompt-sites-cli.test.ts | 6 +- tests/nodes-registry.test.ts | 62 ++++++ tests/preload.ts | 28 +++ tests/secret-store.test.ts | 2 + 14 files changed, 385 insertions(+), 126 deletions(-) create mode 100644 src/lib/keychain.ts create mode 100644 tests/keychain-guard.test.ts create mode 100644 tests/preload.ts diff --git a/bunfig.toml b/bunfig.toml index 269a735d5..e1e144f3b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,10 +1,13 @@ [test] root = "./tests" +# preload: replaces Bun.secrets with an in-memory backend and disables the native +# keychain. It runs first, so the state guard's baseline already has the flag. # global-state-guard: fails a test or file that leaves PATH, HOME, cwd or other # process state changed, and restores it so later files run clean (HACK-1327). # checkout-write-guard: fails the run when a test creates or changes a file in # the checkout (HACK-1343). preload = [ + "./tests/preload.ts", "./tests/helpers/global-state-guard-preload.ts", "./tests/helpers/checkout-write-guard-preload.ts", ] diff --git a/docs/env.md b/docs/env.md index b0160f1a4..9cd783c4a 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1163,3 +1163,17 @@ and group members without a surviving identity are explicitly unverified. See [runtime performance diagnostics](performance.md) for project-listing timings, container details, watcher measurements and read-only cleanup previews. + +### Disabling native keychain access + +`HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1` disables native keychain reads, writes, +and deletes, including `hack secrets`, project keychain stores, and macOS node +auth tokens. All three operations throw an explicit disabled error. +Encrypted-file stores still accept a configured key file or `HACK_SECRETS_FILE_KEY`, +and can generate a new key file when no encrypted store exists. + +`bun test` loads `tests/preload.ts`, which sets this flag and replaces `Bun.secrets` +with an in-memory backend. Tests that exercise keychain behavior can temporarily +clear the flag; the preload still prevents native keychain access. Child CLI +processes inherit the flag, so tests must pass it through when supplying a custom +process environment. The in-memory backend is cleared after each test. diff --git a/src/commands/secrets.ts b/src/commands/secrets.ts index 072eae03d..9379b0c12 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -1,6 +1,4 @@ import { isCancel, password, text } from "@clack/prompts"; - -import { secrets } from "bun"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { @@ -8,6 +6,7 @@ import { confirmSafe, requireInteractive, } from "../lib/interactivity.ts"; +import { keychain } from "../lib/keychain.ts"; import { logger } from "../ui/logger.ts"; const DEFAULT_SECRETS_SERVICE = "hack-cli" as const; @@ -146,7 +145,7 @@ async function handleSecretsSet({ return 1; } - await secrets.set({ service, name, value }); + await keychain.set({ service, name, value }); logger.success({ message: `Stored secret "${name}" under service "${service}"`, }); @@ -164,7 +163,7 @@ async function handleSecretsGet({ [args.positionals.name].filter((v) => typeof v === "string") ); - const value = await secrets.get({ service, name }); + const value = await keychain.get({ service, name }); if (value === null) { logger.warn({ message: `No secret found for "${name}" (${service})` }); return 1; @@ -206,7 +205,7 @@ async function handleSecretsDelete({ return 0; } - const deleted = await secrets.delete({ service, name }); + const deleted = await keychain.delete({ service, name }); if (!deleted) { logger.warn({ message: `No secret found to delete for "${name}" (${service})`, diff --git a/src/control-plane/extensions/tailscale/commands.ts b/src/control-plane/extensions/tailscale/commands.ts index ac592fc48..87c111f9a 100644 --- a/src/control-plane/extensions/tailscale/commands.ts +++ b/src/control-plane/extensions/tailscale/commands.ts @@ -1,4 +1,3 @@ -import { secrets } from "bun"; import { updateGlobalConfig } from "../../../lib/config.ts"; import { getRecord, @@ -6,6 +5,7 @@ import { getStringArray, isRecord, } from "../../../lib/guards.ts"; +import { keychain } from "../../../lib/keychain.ts"; import { exec, findExecutableInPath } from "../../../lib/shell.ts"; import { display } from "../../../ui/display.ts"; @@ -191,7 +191,7 @@ export const TAILSCALE_COMMANDS: readonly ExtensionCommand[] = [ fallback: DEFAULT_TAILSCALE_KEY_EXPIRY_SECONDS, }); - await secrets.set({ + await keychain.set({ service: TAILSCALE_OAUTH_SECRET_SERVICE, name: authRef, value: secret.clientSecret, @@ -265,7 +265,7 @@ export const TAILSCALE_COMMANDS: readonly ExtensionCommand[] = [ parsed.value.authRef?.trim() || settings.authRef || DEFAULT_TAILSCALE_OAUTH_AUTH_REF; - const deleted = await secrets.delete({ + const deleted = await keychain.delete({ service: TAILSCALE_OAUTH_SECRET_SERVICE, name: authRef, }); @@ -999,7 +999,7 @@ async function validateTailscaleOauthPayload(opts: { } const clientSecret = ( - (await secrets.get({ + (await keychain.get({ service: TAILSCALE_OAUTH_SECRET_SERVICE, name: authRef, })) ?? "" diff --git a/src/lib/keychain.ts b/src/lib/keychain.ts new file mode 100644 index 000000000..3e086c927 --- /dev/null +++ b/src/lib/keychain.ts @@ -0,0 +1,54 @@ +/** The single boundary for native keychain access, injectable for isolated callers. */ +export type KeychainBackend = { + get(input: { service: string; name: string }): Promise; + set(input: { service: string; name: string; value: string }): Promise; + delete(input: { service: string; name: string }): Promise; +}; + +export function isKeychainDisabled(): boolean { + return ["1", "true", "yes", "on"].includes( + (process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK ?? "") + .trim() + .toLowerCase() + ); +} + +function assertKeychainEnabled(): void { + if (isKeychainDisabled()) { + throw new Error( + "Keychain access is disabled by HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK." + ); + } +} + +/** Check on every operation so changing the flag also protects existing stores. */ +export function createKeychainBackend({ + backend, +}: { + readonly backend: KeychainBackend; +}): KeychainBackend { + return { + get: async (input) => { + assertKeychainEnabled(); + return await backend.get(input); + }, + set: async (input) => { + assertKeychainEnabled(); + await backend.set(input); + }, + delete: async (input) => { + assertKeychainEnabled(); + return await backend.delete(input); + }, + }; +} + +export const keychain = createKeychainBackend({ + backend: { + get: async (input) => await Bun.secrets.get(input), + set: async (input) => { + await Bun.secrets.set(input); + }, + delete: async (input) => await Bun.secrets.delete(input), + }, +}); diff --git a/src/lib/nodes-registry.ts b/src/lib/nodes-registry.ts index f3e95bf49..1f30071e0 100644 --- a/src/lib/nodes-registry.ts +++ b/src/lib/nodes-registry.ts @@ -10,6 +10,7 @@ import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, readTextFile } from "./fs.ts"; import { getString, isRecord } from "./guards.ts"; +import { createKeychainBackend } from "./keychain.ts"; import { resolveSecretStore, type SecretStore } from "./secret-store.ts"; import { exec } from "./shell.ts"; @@ -485,97 +486,107 @@ function resolveMacOsNodeAuthSecretStore(): SecretStore | null { if (!(process.platform === "darwin" && Bun.which("security"))) { return null; } + const backend = createKeychainBackend({ + backend: { + get: async ({ name: key }) => { + const found = await exec( + [ + "security", + "find-generic-password", + "-s", + NODE_KEYCHAIN_SERVICE, + "-a", + key, + "-w", + ], + { stdin: "ignore" } + ); + if (found.exitCode === 0) { + const token = found.stdout.trim(); + return token.length > 0 ? token : null; + } + const errorText = `${found.stderr}\n${found.stdout}`.toLowerCase(); + if ( + errorText.includes("could not be found") || + errorText.includes("item could not be found") + ) { + return null; + } + throw new Error( + normalizeSecurityErrorMessage({ + action: "read", + detail: found.stderr || found.stdout, + }) + ); + }, + set: async ({ name: key, value }) => { + const saved = await exec( + [ + "security", + "add-generic-password", + "-U", + "-s", + NODE_KEYCHAIN_SERVICE, + "-a", + key, + "-w", + value, + ], + { stdin: "ignore" } + ); + if (saved.exitCode === 0) { + return; + } + throw new Error( + normalizeSecurityErrorMessage({ + action: "write", + detail: saved.stderr || saved.stdout, + }) + ); + }, + delete: async ({ name: key }) => { + const deleted = await exec( + [ + "security", + "delete-generic-password", + "-s", + NODE_KEYCHAIN_SERVICE, + "-a", + key, + ], + { stdin: "ignore" } + ); + if (deleted.exitCode === 0) { + return true; + } + const errorText = `${deleted.stderr}\n${deleted.stdout}`.toLowerCase(); + if ( + errorText.includes("could not be found") || + errorText.includes("item could not be found") + ) { + return false; + } + throw new Error( + normalizeSecurityErrorMessage({ + action: "delete", + detail: deleted.stderr || deleted.stdout, + }) + ); + }, + }, + }); return { descriptor: { backend: "keychain", location: NODE_KEYCHAIN_SERVICE, mode: "native", }, - get: async ({ key }) => { - const found = await exec( - [ - "security", - "find-generic-password", - "-s", - NODE_KEYCHAIN_SERVICE, - "-a", - key, - "-w", - ], - { stdin: "ignore" } - ); - if (found.exitCode === 0) { - const token = found.stdout.trim(); - return token.length > 0 ? token : null; - } - const errorText = `${found.stderr}\n${found.stdout}`.toLowerCase(); - if ( - errorText.includes("could not be found") || - errorText.includes("item could not be found") - ) { - return null; - } - throw new Error( - normalizeSecurityErrorMessage({ - action: "read", - detail: found.stderr || found.stdout, - }) - ); - }, - set: async ({ key, value }) => { - const saved = await exec( - [ - "security", - "add-generic-password", - "-U", - "-s", - NODE_KEYCHAIN_SERVICE, - "-a", - key, - "-w", - value, - ], - { stdin: "ignore" } - ); - if (saved.exitCode === 0) { - return; - } - throw new Error( - normalizeSecurityErrorMessage({ - action: "write", - detail: saved.stderr || saved.stdout, - }) - ); - }, - delete: async ({ key }) => { - const deleted = await exec( - [ - "security", - "delete-generic-password", - "-s", - NODE_KEYCHAIN_SERVICE, - "-a", - key, - ], - { stdin: "ignore" } - ); - if (deleted.exitCode === 0) { - return true; - } - const errorText = `${deleted.stderr}\n${deleted.stdout}`.toLowerCase(); - if ( - errorText.includes("could not be found") || - errorText.includes("item could not be found") - ) { - return false; - } - throw new Error( - normalizeSecurityErrorMessage({ - action: "delete", - detail: deleted.stderr || deleted.stdout, - }) - ); - }, + get: async ({ key }) => + await backend.get({ service: NODE_KEYCHAIN_SERVICE, name: key }), + set: async ({ key, value }) => + await backend.set({ service: NODE_KEYCHAIN_SERVICE, name: key, value }), + delete: async ({ key }) => + await backend.delete({ service: NODE_KEYCHAIN_SERVICE, name: key }), }; } diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index 31a177cc0..eb210be4c 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -6,7 +6,6 @@ import { } from "node:crypto"; import { stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { secrets } from "bun"; import type { CloudSecretProvider, SecretsBackend, @@ -15,6 +14,7 @@ import type { import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, readTextFile } from "./fs.ts"; +import { isKeychainDisabled, keychain } from "./keychain.ts"; import { decodeCanonicalBase64, publishPrivateFileExclusive, @@ -157,19 +157,19 @@ function createKeychainSecretStore(input: { mode: "native", }, get: async ({ key }) => - await secrets.get({ + await keychain.get({ service, name: key, }), set: async ({ key, value }) => { - await secrets.set({ + await keychain.set({ service, name: key, value, }); }, delete: async ({ key }) => - await secrets.delete({ + await keychain.delete({ service, name: key, }), @@ -597,19 +597,7 @@ async function resolveEncryptedFileKeyMaterial(input?: { } } - if ( - isTruthyEnvFlag(process.env[DISABLE_ENCRYPTED_FILE_KEYCHAIN_FALLBACK_ENV]) - ) { - throw new Error( - buildEncryptedBackendKeyRecoveryMessage({ - keyPath: configuredKeyPath, - storePath: input?.storePath, - reason: "keychain_disabled", - }) - ); - } - - if (cachedEncryptedFileKey) { + if (!isKeychainDisabled() && cachedEncryptedFileKey) { if (allowKeyProvisioning && input?.persistResolvedKey && input.keyPath) { await writeEncryptedFileKey({ keyPath: input.keyPath, @@ -658,6 +646,16 @@ async function resolveEncryptedFileKeyMaterial(input?: { } } + if (isKeychainDisabled()) { + throw new Error( + buildEncryptedBackendKeyRecoveryMessage({ + keyPath: configuredKeyPath, + storePath: input?.storePath, + reason: "keychain_disabled", + }) + ); + } + const nowMs = Date.now(); if (nowMs < encryptedFileKeyFailureCooldownUntilMs) { throw new Error( @@ -672,7 +670,7 @@ async function resolveEncryptedFileKeyMaterial(input?: { let existing: string | undefined; try { existing = ( - await secrets.get({ + await keychain.get({ service: ENCRYPTED_FILE_KEY_SERVICE, name: ENCRYPTED_FILE_KEY_NAME, }) @@ -715,7 +713,7 @@ async function resolveEncryptedFileKeyMaterial(input?: { const generated = randomBytes(32).toString("base64url"); try { - await secrets.set({ + await keychain.set({ service: ENCRYPTED_FILE_KEY_SERVICE, name: ENCRYPTED_FILE_KEY_NAME, value: generated, @@ -1025,13 +1023,3 @@ function buildEncryptedBackendKeyRecoveryMessage(input: { return `Missing encrypted backend key${location}. ${recovery} Hack will not fall back to plaintext .hack/.env or ambient process env.`; } - -function isTruthyEnvFlag(value: string | undefined): boolean { - const normalized = (value ?? "").trim().toLowerCase(); - return ( - normalized === "1" || - normalized === "true" || - normalized === "yes" || - normalized === "on" - ); -} diff --git a/tests/env-backend-command.test.ts b/tests/env-backend-command.test.ts index 676e74d5f..1355507bf 100644 --- a/tests/env-backend-command.test.ts +++ b/tests/env-backend-command.test.ts @@ -152,7 +152,7 @@ test( "use", "encrypted_file", "--store-path", - "/tmp/custom-secrets.enc.json", + resolve(tempDir, "secrets.enc.json"), "--key-path", keyPath, "--provision-key", @@ -160,6 +160,7 @@ test( ], env: { HACK_GLOBAL_CONFIG_PATH: tempGlobalConfigPath, + HACK_SECRETS_FILE_KEY: "", }, }); expect(result.exitCode).toBe(0); @@ -175,7 +176,7 @@ test( expect(json.encrypted_file.keyPath).toBe(keyPath); const keyText = await readFile(keyPath, "utf8"); - expect(keyText.trim().length).toBeGreaterThan(10); + expect(keyText.trim()).toMatch(/^[A-Za-z0-9_-]{43}$/); const configText = await readFile(tempGlobalConfigPath, "utf8"); expect(configText).toContain('"keyPath"'); diff --git a/tests/env-get.test.ts b/tests/env-get.test.ts index 08ae73132..353d4b546 100644 --- a/tests/env-get.test.ts +++ b/tests/env-get.test.ts @@ -333,6 +333,9 @@ test("legacy get reads only the requested backend key and preserves empty overla { key: "OTHER", source: "keychain" }, ]); const calls: string[] = []; + // tests/preload.ts keeps this in memory; the gate only has to be open. + const keychainFlag = process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK; + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; const spy = spyOn(secrets, "get").mockImplementation(async (opts) => { expect(opts.service).toBe("hack-env-get-fixture"); calls.push(opts.name); @@ -352,6 +355,7 @@ test("legacy get reads only the requested backend key and preserves empty overla expect(calls).toEqual([]); } finally { spy.mockRestore(); + restoreEnv("HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK", keychainFlag); } }); diff --git a/tests/keychain-guard.test.ts b/tests/keychain-guard.test.ts new file mode 100644 index 000000000..c742b0055 --- /dev/null +++ b/tests/keychain-guard.test.ts @@ -0,0 +1,89 @@ +import { afterEach, expect, test } from "bun:test"; +import { secrets } from "bun"; +import { createKeychainBackend, keychain } from "../src/lib/keychain.ts"; +import { resolveSecretStore } from "../src/lib/secret-store.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const disableFlag = process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK; +afterEach(() => { + restoreEnv("HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK", disableFlag); +}); + +test("preload replaces both Bun.secrets entry points and prevents restoration", async () => { + expect(Object.getOwnPropertyDescriptor(Bun, "secrets")?.configurable).toBe( + false + ); + expect(secrets).toBe(Bun.secrets); + expect(() => Object.defineProperty(Bun, "secrets", { value: {} })).toThrow(); + const input = { service: "guard", name: "SECRET_TWO" }; + await secrets.set({ ...input, value: "memory-only" }); + expect(await secrets.get(input)).toBe("memory-only"); + expect(await secrets.delete(input)).toBe(true); + expect(await secrets.get(input)).toBeNull(); +}); + +test("disable flag gates every operation before even an injected backend is called", async () => { + const calls: string[] = []; + const backend = createKeychainBackend({ + backend: { + get: async () => { + calls.push("get"); + return "value"; + }, + set: async () => { + calls.push("set"); + }, + delete: async () => { + calls.push("delete"); + return true; + }, + }, + }); + const input = { service: "guard", name: "SECRET_TWO" }; + for (const flag of ["1", "true", "yes", "on", " TRUE "]) { + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = flag; + await expect(backend.get(input)).rejects.toThrow( + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); + await expect(backend.set({ ...input, value: "value" })).rejects.toThrow( + "disabled" + ); + await expect(backend.delete(input)).rejects.toThrow( + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); + } + expect(calls).toEqual([]); + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + expect(await backend.get(input)).toBe("value"); + await backend.set({ ...input, value: "value" }); + expect(await backend.delete(input)).toBe(true); + expect(calls).toEqual(["get", "set", "delete"]); +}); + +test("existing keychain stores honor changes to the flag without native calls", async () => { + const previousConfig = process.env.HACK_GLOBAL_CONFIG_PATH; + try { + process.env.HACK_GLOBAL_CONFIG_PATH = "/dev/null"; + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + const store = await resolveSecretStore({ projectName: "hermetic-guard" }); + expect(store.descriptor.backend).toBe("keychain"); + await store.set({ key: "SECRET_TWO", value: "memory-only" }); + expect(await store.get({ key: "SECRET_TWO" })).toBe("memory-only"); + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "1"; + await expect(store.get({ key: "SECRET_TWO" })).rejects.toThrow("disabled"); + await expect( + store.set({ key: "SECRET_TWO", value: "blocked" }) + ).rejects.toThrow("disabled"); + await expect(store.delete({ key: "SECRET_TWO" })).rejects.toThrow( + "disabled" + ); + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + expect(await store.get({ key: "SECRET_TWO" })).toBe("memory-only"); + expect(await store.delete({ key: "SECRET_TWO" })).toBe(true); + expect( + await keychain.get({ service: "hack-hermetic-guard", name: "SECRET_TWO" }) + ).toBeNull(); + } finally { + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousConfig); + } +}); diff --git a/tests/no-tty-prompt-sites-cli.test.ts b/tests/no-tty-prompt-sites-cli.test.ts index 48742691b..8a2cac624 100644 --- a/tests/no-tty-prompt-sites-cli.test.ts +++ b/tests/no-tty-prompt-sites-cli.test.ts @@ -237,7 +237,11 @@ async function secretsLog(): Promise { } function secretsEnv(): Record { - return { HACK_TEST_SECRETS_LOG: join(requireCaseRoot(), "secrets.log") }; + // The child preload stubs the keychain, so its gate can stay open. + return { + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "0", + HACK_TEST_SECRETS_LOG: join(requireCaseRoot(), "secrets.log"), + }; } test( diff --git a/tests/nodes-registry.test.ts b/tests/nodes-registry.test.ts index ba958583a..95076e8d6 100644 --- a/tests/nodes-registry.test.ts +++ b/tests/nodes-registry.test.ts @@ -256,3 +256,65 @@ async function writeGlobalSecretsConfig(input: { }; await writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`); } + +test("node keychain auth rejects reads, writes and deletes when disabled", async () => { + await writeGlobalSecretsConfig({ backend: "keychain" }); + const authRef = "node.auth.disabled"; + await expect(readNodeAuthToken({ authRef })).rejects.toThrow( + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); + await expect( + saveNodeAuthToken({ authRef, token: "blocked" }) + ).rejects.toThrow("HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK"); + await expect(deleteNodeAuthToken({ authRef })).rejects.toThrow( + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); +}); + +test.skipIf(process.platform !== "darwin")( + "macOS node auth guards the security CLI before spawning", + async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + await writeGlobalSecretsConfig({ backend: "keychain" }); + const binDir = join(tempDir, "bin"); + await mkdir(binDir); + const callsPath = join(binDir, "calls"); + await writeFile( + join(binDir, "security"), + "#!/bin/sh\n" + + 'printf "%s\\n" "$*" >> "${0%/*}/calls"\n' + + 'printf "fake-token\\n"\n', + { mode: 0o700 } + ); + const previousPath = process.env.PATH; + const previousFlag = process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK; + try { + process.env.PATH = binDir; + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + const authRef = "node.auth.fake-cli"; + await saveNodeAuthToken({ authRef, token: "fake-token" }); + expect(await readNodeAuthToken({ authRef })).toBe("fake-token"); + expect(await deleteNodeAuthToken({ authRef })).toBe(true); + const calls = await readFile(callsPath, "utf8"); + expect(calls).toContain( + "add-generic-password -U -s hack-node-registry -a node.auth.fake-cli -w fake-token" + ); + expect(calls).toContain("find-generic-password -s hack-node-registry"); + expect(calls).toContain("delete-generic-password -s hack-node-registry"); + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "1"; + await expect(readNodeAuthToken({ authRef })).rejects.toThrow("disabled"); + await expect( + saveNodeAuthToken({ authRef, token: "blocked" }) + ).rejects.toThrow("disabled"); + await expect(deleteNodeAuthToken({ authRef })).rejects.toThrow( + "disabled" + ); + expect(await readFile(callsPath, "utf8")).toBe(calls); + } finally { + process.env.PATH = previousPath; + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = previousFlag; + } + } +); diff --git a/tests/preload.ts b/tests/preload.ts new file mode 100644 index 000000000..82444da71 --- /dev/null +++ b/tests/preload.ts @@ -0,0 +1,28 @@ +import { afterEach, expect } from "bun:test"; +import type { KeychainBackend } from "../src/lib/keychain.ts"; + +process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "1"; + +const values = new Map(); +function entryKey(input: { service: string; name: string }): string { + return JSON.stringify([input.service, input.name]); +} + +const testSecrets: KeychainBackend = { + get: async (input) => values.get(entryKey(input)) ?? null, + set: async (input) => { + values.set(entryKey(input), input.value); + }, + delete: async (input) => values.delete(entryKey(input)), +}; + +Object.defineProperty(Bun, "secrets", { + configurable: false, + writable: false, + value: testSecrets, +}); + +afterEach(() => { + expect(Bun.secrets).toBe(testSecrets); + values.clear(); +}); diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index d251f39a3..047abf9b3 100644 --- a/tests/secret-store.test.ts +++ b/tests/secret-store.test.ts @@ -193,6 +193,8 @@ test("encrypted_file backend reads key material from configured keyPath when env }); test("project config resolves encrypted_file paths relative to the repo root", async () => { + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + if (!tempDir) { throw new Error("Missing temp dir"); } From ac12697c3016e9347a2c9fd67dda8b71c3da58f1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:02:06 -0500 Subject: [PATCH 04/19] fix: never resolve global state under the cwd for empty or relative HOME/HACK_HOME (HACK-1758) Forward main's 98ab4e70 (HACK-1357). An empty or relative HOME, or a relative HACK_HOME, made Hack resolve the registry, daemon socket and secret key paths under /.hack. src/lib/home-dir.ts now normalizes the home, refuses a relative HACK_HOME and throws when no absolute home exists. Beyond main's call sites, next has the same bug in code that main lacks: MCP session capture, launchd access, the native AWS environment and setup migrate. Each now uses the helper, with regression tests for the MCP and launchd paths. MCP sessions keep resolving a relative HACK_HOME against the captured checkout: tests/mcp-session-context.test.ts pins that as next's contract. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 3 +- docs/cli.md | 3 +- src/agents/claude.ts | 8 +- src/agents/codex-skill.ts | 7 +- src/agents/cursor.ts | 7 +- src/agents/hack-init-skill.ts | 7 +- src/agents/legacy-artifacts.ts | 6 +- src/agents/shared-skill.ts | 6 +- src/backends/native-aws-environment.ts | 4 +- src/commands/doctor.ts | 4 +- src/commands/env.ts | 4 +- src/commands/node.ts | 15 +- src/commands/session.ts | 3 +- src/commands/setup-migrate.ts | 3 +- src/commands/setup.ts | 4 +- .../extensions/cloudflare/commands.ts | 10 +- src/daemon/launchd.ts | 8 +- src/daemon/paths.ts | 39 ++++- src/lib/config-paths.ts | 22 +-- src/lib/home-dir.ts | 77 +++++++++ src/lib/project-env-config.ts | 6 +- src/lib/secret-store.ts | 8 +- src/mcp/install.ts | 7 +- src/mcp/session-context.ts | 7 +- tests/config-paths.test.ts | 154 +++++++++++++++++- tests/home-dir.test.ts | 83 ++++++++++ tests/launchd-isolation.test.ts | 8 + tests/mcp-session-context.test.ts | 13 ++ tests/secret-store.test.ts | 43 ++++- 29 files changed, 499 insertions(+), 70 deletions(-) create mode 100644 src/lib/home-dir.ts create mode 100644 tests/home-dir.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index eebd69047..70d6162da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,7 +368,8 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl ## Files and directories -- `~/.hack/` (default; override the whole root with `HACK_HOME`) +- `~/.hack/` (default; override the whole root with `HACK_HOME`, which must be an absolute path. + An empty or relative `HOME` is treated as unset, so global state never lands under the cwd.) - `hack.config.json` (global config: control-plane defaults, gateway bind/port/allowWrites, extension settings) - `daemon/hackd.sock` diff --git a/docs/cli.md b/docs/cli.md index 070c64630..12a19078c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -679,7 +679,8 @@ files ever leak into git, `hack doctor --fix` untracks them (the files stay on d `.branch/compose..runtime.override.yml` for branch instances. See [Architecture](architecture.md) for the full file map. -The global config root defaults to `~/.hack`; override it with `HACK_HOME`. +The global config root defaults to `~/.hack`; override it with `HACK_HOME` (an absolute path; +a relative value is refused). An empty or relative `HOME` falls back to the OS home directory. ## Lifecycle diff --git a/src/agents/claude.ts b/src/agents/claude.ts index 96813ea0f..275bcc556 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -1,5 +1,4 @@ import { dirname, resolve } from "node:path"; - import { ensureDir, pathExists, @@ -7,6 +6,7 @@ import { writeTextFileIfChanged, } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { checkHackInitSkill, installHackInitSkill, @@ -298,7 +298,8 @@ function resolveClaudeSettingsPath(opts: { if (!root) { return { ok: false, - message: "HOME is not set; cannot resolve Claude settings path.", + message: + "HOME is not set to an absolute path; cannot resolve Claude settings path.", }; } @@ -492,6 +493,5 @@ function createHook(opts: { } function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; + return readHomeEnv(); } diff --git a/src/agents/codex-skill.ts b/src/agents/codex-skill.ts index ff89c1ba5..6532313d6 100644 --- a/src/agents/codex-skill.ts +++ b/src/agents/codex-skill.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { readOptionalTextFile } from "../lib/fs.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { checkHackInitSkill, installHackInitSkill, @@ -307,7 +308,8 @@ function resolveCodexSkillPath(opts: { if (!root) { return { ok: false, - message: "HOME is not set; cannot resolve Codex skill path.", + message: + "HOME is not set to an absolute path; cannot resolve Codex skill path.", }; } @@ -318,8 +320,7 @@ function resolveCodexSkillPath(opts: { } function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; + return readHomeEnv(); } function resolveInstallStatus(opts: { diff --git a/src/agents/cursor.ts b/src/agents/cursor.ts index 7106cc21a..d7fbf5c0e 100644 --- a/src/agents/cursor.ts +++ b/src/agents/cursor.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { readOptionalTextFile } from "../lib/fs.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { normalizeInstructionText, renderInstructionSections, @@ -208,7 +209,8 @@ function resolveCursorRulesPath(opts: { if (!root) { return { ok: false, - message: "HOME is not set; cannot resolve user rules path.", + message: + "HOME is not set to an absolute path; cannot resolve user rules path.", }; } @@ -216,6 +218,5 @@ function resolveCursorRulesPath(opts: { } function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; + return readHomeEnv(); } diff --git a/src/agents/hack-init-skill.ts b/src/agents/hack-init-skill.ts index 777156464..9e028091d 100644 --- a/src/agents/hack-init-skill.ts +++ b/src/agents/hack-init-skill.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { readOptionalTextFile } from "../lib/fs.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { normalizeInstructionText } from "./instruction-source.ts"; import { type IntegrationPreservedReason, @@ -247,7 +248,8 @@ function resolveHackInitSkillPath(opts: { if (!root) { return { ok: false, - message: "HOME is not set; cannot resolve hack-init skill path.", + message: + "HOME is not set to an absolute path; cannot resolve hack-init skill path.", }; } @@ -258,6 +260,5 @@ function resolveHackInitSkillPath(opts: { } function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; + return readHomeEnv(); } diff --git a/src/agents/legacy-artifacts.ts b/src/agents/legacy-artifacts.ts index 8209137e3..bba4bd195 100644 --- a/src/agents/legacy-artifacts.ts +++ b/src/agents/legacy-artifacts.ts @@ -2,6 +2,7 @@ import { rm } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { readTextFile, writeTextFileIfChanged } from "../lib/fs.ts"; +import { normalizeHomeDir } from "../lib/home-dir.ts"; export type LegacyAgentArtifactResult = { readonly status: "absent" | "deprecated" | "removed" | "error"; @@ -102,11 +103,12 @@ function resolveLegacyUserSkillDefinitions({ }): | { readonly ok: true; readonly items: readonly LegacySkillDefinition[] } | { readonly ok: false; readonly message: string } { - const resolvedHome = (home ?? "").trim(); + const resolvedHome = normalizeHomeDir(home); if (!resolvedHome) { return { ok: false, - message: "HOME is not set; cannot resolve legacy skills.", + message: + "HOME is not set to an absolute path; cannot resolve legacy skills.", }; } return { diff --git a/src/agents/shared-skill.ts b/src/agents/shared-skill.ts index 6e8b58c44..f0d148687 100644 --- a/src/agents/shared-skill.ts +++ b/src/agents/shared-skill.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { readOptionalTextFile } from "../lib/fs.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { renderCodexSkill } from "./codex-skill.ts"; import { normalizeInstructionText } from "./instruction-source.ts"; import { @@ -132,11 +133,12 @@ function resolveSharedSkillPath(opts: { }): | { readonly ok: true; readonly path: string } | { readonly ok: false; readonly message: string } { - const home = (process.env.HOME ?? "").trim(); + const home = readHomeEnv(); if (!home) { return { ok: false, - message: "HOME is not set; cannot resolve shared skills.", + message: + "HOME is not set to an absolute path; cannot resolve shared skills.", }; } return { diff --git a/src/backends/native-aws-environment.ts b/src/backends/native-aws-environment.ts index 3f0804d3c..4c93af566 100644 --- a/src/backends/native-aws-environment.ts +++ b/src/backends/native-aws-environment.ts @@ -1,8 +1,8 @@ import { spawn } from "node:child_process"; -import { homedir } from "node:os"; import { join } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; const MAX_OUTPUT = 64 * 1024; @@ -340,7 +340,7 @@ export async function adaptNativeAwsEnvironment(opts: { const { managed, selected } = prepareServices( { ...compose, services: compose.services }, opts.input, - opts.homeDirectory ?? homedir() + opts.homeDirectory ?? resolveUserHomeDir() ); const exported = await (opts.exportSource ?? exportAwsProfile)({ profile: opts.profile, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c7f68a0f1..8455f69c9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,4 @@ import { lookup } from "node:dns/promises"; -import { homedir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { note, spinner } from "@clack/prompts"; import { YAML } from "bun"; @@ -119,6 +118,7 @@ import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import { inspectHackEnvOverlayWarnings } from "../lib/hack-env.ts"; +import { normalizeHomeDir, resolveUserHomeDir } from "../lib/home-dir.ts"; import { canPrompt, confirmSafe } from "../lib/interactivity.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; @@ -4423,7 +4423,7 @@ export async function inspectDoctorAgentIntegrations(opts: { readonly projectRoot: string | null; readonly homeDir?: string; }): Promise<{ readonly status: "current" | "stale" }> { - const home = (opts.homeDir ?? process.env.HOME ?? homedir()).trim(); + const home = normalizeHomeDir(opts.homeDir) ?? resolveUserHomeDir(); const hackOwnedPaths = [ ...(opts.projectRoot ? [ diff --git a/src/commands/env.ts b/src/commands/env.ts index d39887a10..aa6d4ea95 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import { dirname, relative, resolve } from "node:path"; import { confirm, isCancel, password, select, text } from "@clack/prompts"; import { YAML } from "bun"; @@ -56,6 +55,7 @@ import { serializeEnvClassificationForJson, serializeEnvStorageForJson as serializeEnvStorageForJsonShape, } from "../lib/hack-env-status.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; import { runObservedHostCommand } from "../lib/host-command-observation.ts"; import { canPrompt, @@ -666,7 +666,7 @@ function normalizeGlobalEncryptedFileConfigPath(input: { return raw; } - const home = (process.env.HOME ?? homedir()).trim(); + const home = resolveUserHomeDir(); const globalConfigDir = dirname(resolveGlobalConfigPath()); const resolvedPath = resolve(globalConfigDir, raw); if (home.length > 0 && resolvedPath.startsWith(`${home}/`)) { diff --git a/src/commands/node.ts b/src/commands/node.ts index 9b7d1f044..dab64ffa9 100644 --- a/src/commands/node.ts +++ b/src/commands/node.ts @@ -8,7 +8,7 @@ import { rm, writeFile, } from "node:fs/promises"; -import { homedir, hostname, tmpdir } from "node:os"; +import { hostname, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { confirm, isCancel, text } from "@clack/prompts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; @@ -21,6 +21,7 @@ import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { createGatewayClient } from "../control-plane/sdk/gateway-client.ts"; import { resolveDaemonPaths } from "../daemon/paths.ts"; import { pathExists } from "../lib/fs.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { cancelNodePairingSession, @@ -5563,12 +5564,16 @@ async function ensureNodePairSshReady(input: { if (!source.ok) { return source; } - const keyPath = join(homedir(), ".ssh", DEFAULT_NODE_PAIR_SSH_KEY_NAME); + const keyPath = join( + resolveUserHomeDir(), + ".ssh", + DEFAULT_NODE_PAIR_SSH_KEY_NAME + ); const keyPair = await ensureNodePairSshKey({ keyPath }); if (!keyPair.ok) { return keyPair; } - const configPath = join(homedir(), ".ssh", "config"); + const configPath = join(resolveUserHomeDir(), ".ssh", "config"); const configWrite = await upsertNodePairSshConfig({ host: source.host, user: source.user, @@ -5646,7 +5651,7 @@ async function ensureNodePairSshKey(input: { > { const keyPath = input.keyPath; const publicKeyPath = `${keyPath}.pub`; - const sshDir = join(homedir(), ".ssh"); + const sshDir = join(resolveUserHomeDir(), ".ssh"); try { await mkdir(sshDir, { recursive: true }); await chmod(sshDir, 0o700); @@ -5778,7 +5783,7 @@ async function upsertNodePairSshConfig(input: { port: input.port, keyPath: input.keyPath, }); - const configDir = join(homedir(), ".ssh"); + const configDir = join(resolveUserHomeDir(), ".ssh"); try { await mkdir(configDir, { recursive: true }); } catch { diff --git a/src/commands/session.ts b/src/commands/session.ts index 2eafb1f7f..e3eb08bc0 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -18,6 +18,7 @@ import { resolveHackEnv, selectHackEnvValues, } from "../lib/hack-env.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { @@ -821,7 +822,7 @@ async function handleSessionPicker(): Promise { const options = buildSessionPickerOptions({ sessions, projects, - home: process.env.HOME ?? "", + home: readHomeEnv() ?? "", }); if (options.length === 0) { diff --git a/src/commands/setup-migrate.ts b/src/commands/setup-migrate.ts index e93177695..302229d66 100644 --- a/src/commands/setup-migrate.ts +++ b/src/commands/setup-migrate.ts @@ -23,6 +23,7 @@ import { CliUsageError, defineCommand, defineOption } from "../cli/command.ts"; import { optJson, optPath } from "../cli/options.ts"; import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { findRepoRootForInit } from "../lib/project.ts"; import { type DisplayStatusItem, display } from "../ui/display.ts"; import { logger } from "../ui/logger.ts"; @@ -217,7 +218,7 @@ export async function handleSetupMigrate({ return await reportRollback({ result, json: options.json === true }); } - const home = (process.env.HOME ?? "").trim(); + const home = readHomeEnv() ?? ""; const scopes: MigrationScopeRoot[] = []; if (!options.global || options.allScopes) { const startDir = resolve(ctx.cwd, options.path ?? "."); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 7005e5ff6..19a307e60 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import { resolve } from "node:path"; import { isCancel, select, text } from "@clack/prompts"; import { RETIRED_AGENT_AUTOMATION_MESSAGE } from "../agents/explicit-integrations.ts"; @@ -20,6 +19,7 @@ import { } from "../cli/command.ts"; import { optJson, optPath } from "../cli/options.ts"; import { pathExists, readTextFile, writeTextFile } from "../lib/fs.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { findRepoRootForInit } from "../lib/project.ts"; import { findExecutableInPath } from "../lib/shell.ts"; @@ -268,7 +268,7 @@ async function resolveTmuxConfigPaths(): Promise<{ readonly homeConfig: string; readonly existingConfigs: readonly string[]; }> { - const home = homedir(); + const home = resolveUserHomeDir(); const xdgConfig = resolve(home, ".config/tmux/tmux.conf"); const homeConfig = resolve(home, ".tmux.conf"); const candidates = [xdgConfig, homeConfig]; diff --git a/src/control-plane/extensions/cloudflare/commands.ts b/src/control-plane/extensions/cloudflare/commands.ts index 8eb052de0..f7bada59d 100644 --- a/src/control-plane/extensions/cloudflare/commands.ts +++ b/src/control-plane/extensions/cloudflare/commands.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import { resolve } from "node:path"; import { GLOBAL_CLOUDFLARE_DIR_NAME } from "../../../constants.ts"; import { @@ -15,6 +14,7 @@ import { writeTextFileIfChanged, } from "../../../lib/fs.ts"; import { getString, isRecord } from "../../../lib/guards.ts"; +import { resolveUserHomeDir } from "../../../lib/home-dir.ts"; import { display } from "../../../ui/display.ts"; import type { ControlPlaneConfig } from "../../sdk/config.ts"; import { readControlPlaneConfig } from "../../sdk/config.ts"; @@ -979,7 +979,7 @@ function resolveOutPath(opts: { }): string { const raw = opts.raw.trim(); if (raw.startsWith("~/")) { - return resolve(homedir(), raw.slice(2)); + return resolve(resolveUserHomeDir(), raw.slice(2)); } return resolve(opts.cwd, raw); } @@ -1092,6 +1092,10 @@ async function findTunnelId(opts: { async function resolveCredentialsFile(opts: { readonly tunnelId: string; }): Promise { - const candidate = resolve(homedir(), ".cloudflared", `${opts.tunnelId}.json`); + const candidate = resolve( + resolveUserHomeDir(), + ".cloudflared", + `${opts.tunnelId}.json` + ); return (await pathExists(candidate)) ? candidate : undefined; } diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index c1f9f9b9e..38c042bc7 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -11,6 +11,7 @@ import { writeTextFile, } from "../lib/fs.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; +import { normalizeHomeDir, resolveUserHomeDir } from "../lib/home-dir.ts"; import { findExecutableInPath } from "../lib/shell.ts"; import type { DaemonPaths } from "./paths.ts"; @@ -73,8 +74,9 @@ export function resolveLaunchdAccess( if (override.length === 0) { return { allowed: true }; } - const home = (env.HOME ?? homedir()).trim(); - if (resolve(override) === resolve(home, GLOBAL_HACK_DIR_NAME)) { + // A home that is not absolute cannot own the service, so it never matches. + const home = normalizeHomeDir(env.HOME) ?? normalizeHomeDir(homedir()); + if (home && resolve(override) === resolve(home, GLOBAL_HACK_DIR_NAME)) { return { allowed: true }; } if ((env[LAUNCHD_ALLOW_ISOLATED_ENV] ?? "").trim() === "1") { @@ -230,7 +232,7 @@ export async function installLaunchdService({ error: "Could not resolve hack binary path for launchd service", }; } - const home = process.env.HOME ?? homedir(); + const home = resolveUserHomeDir(); const plistContent = renderLaunchdPlist({ hackBinPath, diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts index 1580b5883..af3bd5003 100644 --- a/src/daemon/paths.ts +++ b/src/daemon/paths.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import { resolve } from "node:path"; import { @@ -10,6 +9,7 @@ import { GLOBAL_HACK_DIR_NAME, } from "../constants.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { normalizeHomeDir, resolveUserHomeDir } from "../lib/home-dir.ts"; export interface DaemonPaths { readonly root: string; @@ -26,19 +26,46 @@ export function resolveDaemonPaths({ }: { readonly home?: string; }): DaemonPaths { - const baseHome = (home ?? process.env.HOME ?? homedir()).trim(); - const hackDir = home - ? resolve(baseHome, GLOBAL_HACK_DIR_NAME) + const explicitHome = resolveExplicitHome({ home }); + const hackDir = explicitHome + ? resolve(explicitHome, GLOBAL_HACK_DIR_NAME) : resolveGlobalHackDir(); const root = resolve(hackDir, GLOBAL_DAEMON_DIR_NAME); - const launchAgentsDir = resolve(baseHome, "Library", "LaunchAgents"); return { root, socketPath: resolve(root, GLOBAL_DAEMON_SOCKET_FILENAME), pidPath: resolve(root, GLOBAL_DAEMON_PID_FILENAME), logPath: resolve(root, GLOBAL_DAEMON_LOG_FILENAME), - launchdPlistPath: resolve(launchAgentsDir, DAEMON_LAUNCHD_PLIST_FILENAME), + // Resolved on access: daemon state only needs the hack dir (which an + // absolute HACK_HOME supplies), so an unusable HOME must fail launchd + // operations alone, not every daemon client. + get launchdPlistPath() { + const baseHome = explicitHome ?? resolveUserHomeDir(); + return resolve( + baseHome, + "Library", + "LaunchAgents", + DAEMON_LAUNCHD_PLIST_FILENAME + ); + }, launchdStdoutPath: resolve(root, "hackd.stdout.log"), launchdStderrPath: resolve(root, "hackd.stderr.log"), }; } + +function resolveExplicitHome({ + home, +}: { + readonly home: string | undefined; +}): string | null { + if (home === undefined || home.trim().length === 0) { + return null; + } + const normalized = normalizeHomeDir(home); + if (!normalized) { + throw new Error( + `Daemon home must be an absolute path (got ${JSON.stringify(home)}).` + ); + } + return normalized; +} diff --git a/src/lib/config-paths.ts b/src/lib/config-paths.ts index 0ebbf3861..e3d941a24 100644 --- a/src/lib/config-paths.ts +++ b/src/lib/config-paths.ts @@ -1,25 +1,25 @@ -import { homedir } from "node:os"; import { resolve } from "node:path"; import { GLOBAL_CONFIG_FILENAME, GLOBAL_HACK_DIR_NAME } from "../constants.ts"; +import { readHackHomeOverride, resolveUserHomeDir } from "./home-dir.ts"; /** * Resolve the global hack home directory (`~/.hack` by default). * * Resolution order: - * 1. `HACK_HOME` env var (trimmed, non-empty) — used as-is, resolved to an - * absolute path. This override exists for tests and isolated environments - * that need to redirect ALL global state (registry, caddy assets, daemon - * state, secrets) without overriding `HOME`. - * 2. `resolve(HOME ?? homedir(), ".hack")` — the default behavior. + * 1. `HACK_HOME` env var (trimmed, non-empty) — used as-is. It must be an + * absolute path; a relative value throws. This override exists for tests + * and isolated environments that need to redirect ALL global state + * (registry, caddy assets, daemon state, secrets) without overriding `HOME`. + * 2. `/.hack`, where an empty or relative `HOME` is treated as unset + * (see {@link resolveUserHomeDir}). Never resolves against the cwd. */ export function resolveGlobalHackDir(): string { - const override = (process.env.HACK_HOME ?? "").trim(); - if (override.length > 0) { - return resolve(override); + const override = readHackHomeOverride(); + if (override) { + return override; } - const home = (process.env.HOME ?? homedir()).trim(); - return resolve(home, GLOBAL_HACK_DIR_NAME); + return resolve(resolveUserHomeDir(), GLOBAL_HACK_DIR_NAME); } /** diff --git a/src/lib/home-dir.ts b/src/lib/home-dir.ts new file mode 100644 index 000000000..85e23eae7 --- /dev/null +++ b/src/lib/home-dir.ts @@ -0,0 +1,77 @@ +import { homedir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; + +/** + * Normalize a home-directory candidate: trimmed and absolute, otherwise null. + * + * Empty and relative values are rejected because `resolve("")` and + * `resolve("relative")` land under the current working directory, which would + * put global state (registry, daemon socket, secret key material) in ``. + */ +export function normalizeHomeDir(value: string | undefined): string | null { + const trimmed = (value ?? "").trim(); + if (trimmed.length === 0 || !isAbsolute(trimmed)) { + return null; + } + return resolve(trimmed); +} + +/** `HOME` when it is set to an absolute path; null when empty, unset or relative. */ +export function readHomeEnv(): string | null { + return normalizeHomeDir(process.env.HOME); +} + +/** + * Resolve the user's home directory without ever falling back to the cwd. + * + * Resolution order: + * 1. `HOME` when set to an absolute path. + * 2. `os.homedir()` when it returns an absolute path (it also consults `HOME` + * on POSIX, so it is only a fallback for an empty or unset `HOME`). + * 3. Otherwise throw: there is no safe home to build global paths from. + */ +export function resolveUserHomeDir({ + osHomedir = homedir, +}: { + readonly osHomedir?: () => string; +} = {}): string { + const fromEnv = readHomeEnv(); + if (fromEnv) { + return fromEnv; + } + let fromOs: string | null = null; + try { + fromOs = normalizeHomeDir(osHomedir()); + } catch { + fromOs = null; + } + if (fromOs) { + return fromOs; + } + throw new Error( + "Cannot resolve the home directory: HOME is empty or not an absolute path. " + + "Set HOME (or HACK_HOME) to an absolute path." + ); +} + +/** + * Read the `HACK_HOME` override. + * + * Returns null when unset or whitespace-only. A relative value is refused with + * an error instead of being resolved against the cwd or silently ignored: + * HACK_HOME exists to isolate global state, so falling back to the real home + * would be as wrong as writing under the cwd. + */ +export function readHackHomeOverride(): string | null { + const raw = (process.env.HACK_HOME ?? "").trim(); + if (raw.length === 0) { + return null; + } + const normalized = normalizeHomeDir(raw); + if (!normalized) { + throw new Error( + `HACK_HOME must be an absolute path (got a relative path: ${JSON.stringify(raw)}).` + ); + } + return normalized; +} diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index daa230e38..9a3faa712 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -57,6 +57,7 @@ import { readHackEnvContract, resolveHackEnv, } from "./hack-env.ts"; +import { resolveUserHomeDir } from "./home-dir.ts"; import { isOwnedLegacyAdoptionManagedEnvAdmission, type LegacyAdoptionManagedEnvAdmission, @@ -5663,12 +5664,11 @@ function resolveConfiguredProjectPath(opts: { readonly configuredPath: string; }): string { const raw = opts.configuredPath.trim(); - const home = (process.env.HOME ?? "").trim(); if (raw === "~") { - return home; + return resolveUserHomeDir(); } if (raw.startsWith("~/")) { - return resolve(home, raw.slice(2)); + return resolve(resolveUserHomeDir(), raw.slice(2)); } if (raw.startsWith("/")) { return raw; diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index eb210be4c..178fefa8f 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -14,6 +14,7 @@ import type { import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, readTextFile } from "./fs.ts"; +import { resolveUserHomeDir } from "./home-dir.ts"; import { isKeychainDisabled, keychain } from "./keychain.ts"; import { decodeCanonicalBase64, @@ -502,9 +503,8 @@ function resolveConfiguredPath(input: { readonly projectDir?: string; }): string { const raw = input.path.trim(); - const home = (process.env.HOME ?? "").trim(); if (raw === "~") { - return home; + return resolveUserHomeDir(); } if (raw === "~/.hack" || raw.startsWith("~/.hack/")) { // "~/.hack" is the canonical global hack dir; route it through the seam so @@ -515,7 +515,7 @@ function resolveConfiguredPath(input: { : resolveGlobalHackDir(); } if (raw.startsWith("~/")) { - return resolve(home, raw.slice(2)); + return resolve(resolveUserHomeDir(), raw.slice(2)); } if (raw.startsWith("/")) { return raw; @@ -523,7 +523,7 @@ function resolveConfiguredPath(input: { if (input.projectDir) { return resolve(input.projectDir, "..", raw); } - return resolve(home, raw); + return resolve(resolveUserHomeDir(), raw); } export async function provisionEncryptedFileKey(input: { diff --git a/src/mcp/install.ts b/src/mcp/install.ts index 5ee8edb01..a53c96cee 100644 --- a/src/mcp/install.ts +++ b/src/mcp/install.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path"; import { ensureDir, writeTextFileIfChanged } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; +import { readHomeEnv } from "../lib/home-dir.ts"; import { type McpBundleSelection, type McpLaunch, @@ -495,7 +496,8 @@ function resolveConfigPath(opts: { if (opts.scope === "user" && !home) { return { ok: false, - message: "HOME is not set; cannot resolve user config path.", + message: + "HOME is not set to an absolute path; cannot resolve user config path.", }; } @@ -563,6 +565,5 @@ function createOkParseResult(opts: { } function resolveHomeDir(): string | null { - const home = (process.env.HOME ?? "").trim(); - return home.length > 0 ? home : null; + return readHomeEnv(); } diff --git a/src/mcp/session-context.ts b/src/mcp/session-context.ts index 8ed1cec92..0caa5e2a3 100644 --- a/src/mcp/session-context.ts +++ b/src/mcp/session-context.ts @@ -1,6 +1,7 @@ import { userInfo } from "node:os"; import { dirname, resolve } from "node:path"; import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; +import { normalizeHomeDir } from "../lib/home-dir.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; export type McpSessionContext = { @@ -30,7 +31,11 @@ export function captureMcpSessionContext(opts: { const override = env.HACK_HOME?.trim(); const hackHome = override ? resolve(cwd, override) - : resolve(cwd, (env.HOME ?? userInfo().homedir).trim(), ".hack"); + : resolve( + // An empty or relative HOME must not put global state under the checkout. + normalizeHomeDir(env.HOME) ?? resolve(userInfo().homedir), + ".hack" + ); const configOverride = env.HACK_GLOBAL_CONFIG_PATH?.trim(); const registryRoot = configOverride ? dirname(resolve(cwd, configOverride)) diff --git a/tests/config-paths.test.ts b/tests/config-paths.test.ts index 44b1bfa2f..7e782b3a5 100644 --- a/tests/config-paths.test.ts +++ b/tests/config-paths.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { resolveDaemonPaths } from "@daemon/paths.ts"; @@ -8,6 +8,7 @@ import { resolveGlobalConfigPath, resolveGlobalHackDir, } from "@lib/config-paths.ts"; +import { resolveUserHomeDir } from "@lib/home-dir.ts"; import { readProjectsRegistry } from "@lib/projects-registry.ts"; import { GLOBAL_CONFIG_FILENAME, @@ -168,3 +169,154 @@ test("projects registry is read from under HACK_HOME when set", async () => { expect(registry.projects).toHaveLength(1); expect(registry.projects[0]?.id).toBe("test-project-id"); }); + +// HACK-1357: an empty or relative HOME/HACK_HOME must never make global state +// (registry, daemon socket/pid, secret key material) resolve under the cwd. +function expectOutsideCwd(path: string): void { + expect(path.startsWith("/")).toBe(true); + expect(path === process.cwd() || path.startsWith(`${process.cwd()}/`)).toBe( + false + ); +} + +for (const [label, home] of [ + ["empty", ""], + ["whitespace", " "], + ["relative", "relative-home"], + ["dot-relative", "./relative-home"], +] as const) { + test(`resolveGlobalHackDir treats ${label} HOME as unset`, () => { + Reflect.deleteProperty(process.env, "HACK_HOME"); + process.env.HOME = home; + + const dir = resolveGlobalHackDir(); + expectOutsideCwd(dir); + expect(dir).toBe(resolve(homedir(), GLOBAL_HACK_DIR_NAME)); + }); + + test(`resolveGlobalConfigPath stays out of the cwd with ${label} HOME`, () => { + Reflect.deleteProperty(process.env, "HACK_HOME"); + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); + process.env.HOME = home; + + expectOutsideCwd(resolveGlobalConfigPath()); + }); + + test(`resolveDaemonPaths stays out of the cwd with ${label} HOME`, () => { + Reflect.deleteProperty(process.env, "HACK_HOME"); + process.env.HOME = home; + + const paths = resolveDaemonPaths({}); + expect(paths.root).toBe( + resolve(homedir(), GLOBAL_HACK_DIR_NAME, GLOBAL_DAEMON_DIR_NAME) + ); + for (const path of Object.values(paths)) { + expectOutsideCwd(path); + } + }); +} + +test("resolveDaemonPaths treats an empty explicit home as unset", () => { + Reflect.deleteProperty(process.env, "HACK_HOME"); + process.env.HOME = "/tmp/hack-home"; + + const paths = resolveDaemonPaths({ home: "" }); + expect(paths.root).toBe( + resolve("/tmp/hack-home", GLOBAL_HACK_DIR_NAME, GLOBAL_DAEMON_DIR_NAME) + ); + expect( + paths.launchdPlistPath.startsWith("/tmp/hack-home/Library/LaunchAgents/") + ).toBe(true); +}); + +test("resolveDaemonPaths refuses a relative explicit home", () => { + process.env.HOME = "/tmp/hack-home"; + + expect(() => resolveDaemonPaths({ home: "relative-home" })).toThrow( + /absolute path/ + ); +}); + +for (const [label, hackHome] of [ + ["relative", "relative-hack-home"], + ["dot-relative", "./relative-hack-home"], +] as const) { + test(`resolveGlobalHackDir refuses ${label} HACK_HOME`, () => { + process.env.HACK_HOME = hackHome; + process.env.HOME = "/tmp/hack-home"; + + expect(() => resolveGlobalHackDir()).toThrow( + /HACK_HOME must be an absolute path/ + ); + }); + + test(`resolveDaemonPaths refuses ${label} HACK_HOME`, () => { + process.env.HACK_HOME = hackHome; + process.env.HOME = "/tmp/hack-home"; + + expect(() => resolveDaemonPaths({})).toThrow( + /HACK_HOME must be an absolute path/ + ); + }); +} + +test("resolveGlobalHackDir treats empty HACK_HOME as unset", () => { + process.env.HACK_HOME = ""; + process.env.HOME = "/tmp/hack-home"; + + expect(resolveGlobalHackDir()).toBe( + resolve("/tmp/hack-home", GLOBAL_HACK_DIR_NAME) + ); +}); + +test("resolveDaemonPaths honors an absolute HACK_HOME even without a usable HOME", () => { + process.env.HACK_HOME = "/tmp/hack-isolated"; + process.env.HOME = "relative-home"; + + // Daemon state comes from HACK_HOME alone; only the launchd plist needs a + // home, and it is resolved lazily (refusing if no absolute home exists). + const paths = resolveDaemonPaths({}); + const root = resolve("/tmp/hack-isolated", GLOBAL_DAEMON_DIR_NAME); + expect(paths.root).toBe(root); + expect(paths.socketPath.startsWith(`${root}/`)).toBe(true); + expect(paths.pidPath.startsWith(`${root}/`)).toBe(true); + expect(paths.launchdPlistPath.startsWith(`${resolveUserHomeDir()}/`)).toBe( + true + ); +}); + +test("daemon state resolves under HACK_HOME in a process started with a relative HOME", async () => { + // Bun's os.homedir() echoes the HOME a process was started with, so this + // needs a real child process to reproduce (HACK-1357 review). + const cwd = await makeTempDir("hack-rel-home-cwd-"); + const hackHome = await makeTempDir("hack-rel-home-state-"); + const pathsModule = resolve(import.meta.dir, "../src/daemon/paths.ts"); + const script = ` + const { resolveDaemonPaths } = await import(${JSON.stringify(pathsModule)}); + const paths = resolveDaemonPaths({}); + let plist = "ok"; + try { void paths.launchdPlistPath; } catch (error) { plist = String(error); } + console.log(JSON.stringify({ socketPath: paths.socketPath, plist })); + `; + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd, + env: { ...process.env, HOME: "relative-home", HACK_HOME: hackHome }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + const result = JSON.parse(stdout.trim()) as { + readonly socketPath: string; + readonly plist: string; + }; + expect( + result.socketPath.startsWith(resolve(hackHome, GLOBAL_DAEMON_DIR_NAME)) + ).toBe(true); + expect(result.plist).toMatch(/HOME is empty or not an absolute path/); +}); diff --git a/tests/home-dir.test.ts b/tests/home-dir.test.ts new file mode 100644 index 000000000..364ac8302 --- /dev/null +++ b/tests/home-dir.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test } from "bun:test"; + +import { + normalizeHomeDir, + readHackHomeOverride, + readHomeEnv, + resolveUserHomeDir, +} from "@lib/home-dir.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const originalHome = process.env.HOME; +const originalHackHome = process.env.HACK_HOME; + +afterEach(() => { + restoreEnv("HOME", originalHome); + restoreEnv("HACK_HOME", originalHackHome); +}); + +test("normalizeHomeDir accepts only absolute paths", () => { + expect(normalizeHomeDir(undefined)).toBeNull(); + expect(normalizeHomeDir("")).toBeNull(); + expect(normalizeHomeDir(" ")).toBeNull(); + expect(normalizeHomeDir("relative")).toBeNull(); + expect(normalizeHomeDir("./relative")).toBeNull(); + expect(normalizeHomeDir("~/home")).toBeNull(); + expect(normalizeHomeDir(" /tmp/home/ ")).toBe("/tmp/home"); +}); + +test("readHomeEnv ignores empty and relative HOME", () => { + process.env.HOME = ""; + expect(readHomeEnv()).toBeNull(); + process.env.HOME = "relative"; + expect(readHomeEnv()).toBeNull(); + process.env.HOME = "/tmp/hack-home"; + expect(readHomeEnv()).toBe("/tmp/hack-home"); +}); + +test("resolveUserHomeDir prefers an absolute HOME", () => { + process.env.HOME = "/tmp/hack-home"; + expect(resolveUserHomeDir({ osHomedir: () => "/tmp/os-home" })).toBe( + "/tmp/hack-home" + ); +}); + +test("resolveUserHomeDir falls back to os.homedir for empty or relative HOME", () => { + for (const home of ["", "relative"]) { + process.env.HOME = home; + expect(resolveUserHomeDir({ osHomedir: () => "/tmp/os-home" })).toBe( + "/tmp/os-home" + ); + } +}); + +test("resolveUserHomeDir refuses when no absolute home is available", () => { + process.env.HOME = "relative"; + // Bun's os.homedir() echoes a relative HOME back; it must not be used. + expect(() => resolveUserHomeDir({ osHomedir: () => "relative" })).toThrow( + /HOME is empty or not an absolute path/ + ); + expect(() => resolveUserHomeDir({ osHomedir: () => "" })).toThrow( + /HOME is empty or not an absolute path/ + ); + expect(() => + resolveUserHomeDir({ + osHomedir: () => { + throw new Error("no passwd entry"); + }, + }) + ).toThrow(/HOME is empty or not an absolute path/); +}); + +test("readHackHomeOverride treats empty as unset and refuses relative", () => { + Reflect.deleteProperty(process.env, "HACK_HOME"); + expect(readHackHomeOverride()).toBeNull(); + process.env.HACK_HOME = " "; + expect(readHackHomeOverride()).toBeNull(); + process.env.HACK_HOME = "/tmp/hack-isolated"; + expect(readHackHomeOverride()).toBe("/tmp/hack-isolated"); + process.env.HACK_HOME = "relative"; + expect(() => readHackHomeOverride()).toThrow( + /HACK_HOME must be an absolute path/ + ); +}); diff --git a/tests/launchd-isolation.test.ts b/tests/launchd-isolation.test.ts index 9eed35825..34da980fd 100644 --- a/tests/launchd-isolation.test.ts +++ b/tests/launchd-isolation.test.ts @@ -564,3 +564,11 @@ for (const autostartCase of AUTOSTART_CASES) { ).toBe(started); }); } + +test("a relative HOME cannot make a cwd-relative HACK_HOME look like the owner", () => { + expect( + resolveLaunchdAccess({ + env: { HOME: "relative-home", HACK_HOME: "relative-home/.hack" }, + }).allowed + ).toBe(false); +}); diff --git a/tests/mcp-session-context.test.ts b/tests/mcp-session-context.test.ts index 0c6423df0..a66d4c32f 100644 --- a/tests/mcp-session-context.test.ts +++ b/tests/mcp-session-context.test.ts @@ -170,6 +170,19 @@ test("session paths resolve relative overrides against the captured checkout", ( expect(Object.isFrozen(context)).toBe(true); }); +test.each([ + ["empty", ""], + ["whitespace", " "], + ["relative", "relative-home"], +])("session state stays out of the checkout with %s HOME", (_label, home) => { + const context = captureMcpSessionContext({ + cwd: "/tmp/captured-session", + env: { HOME: home }, + }); + expect(context.hackHome.startsWith("/tmp/captured-session")).toBe(false); + expect(context.hackHome.endsWith("/.hack")).toBe(true); +}); + test("32 MCP sessions can share a process without mixing command context", async () => { const sessions = await Promise.all( Array.from({ length: 32 }, (_, index) => fixture(`session-${index}`)) diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index 047abf9b3..c35a00bf8 100644 --- a/tests/secret-store.test.ts +++ b/tests/secret-store.test.ts @@ -10,11 +10,12 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { provisionEncryptedFileKey, resolveSecretStore, + resolveSecretStoreDescriptor, } from "../src/lib/secret-store.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -736,3 +737,43 @@ test("encrypted file key provisioning refuses a symlinked parent directory", asy ).rejects.toThrow("Refused encrypted secret key write"); await expect(access(join(target, "key"))).rejects.toThrow(); }); + +// HACK-1357: "~" paths must never resolve under the cwd when HOME is empty +// or relative. Only the descriptor is resolved; nothing is read or written. +for (const [label, home] of [ + ["empty", ""], + ["relative", "relative-home"], +] as const) { + test(`encrypted_file "~" paths stay out of the cwd with ${label} HOME`, async () => { + const previousHackHome = process.env.HACK_HOME; + Reflect.deleteProperty(process.env, "HACK_HOME"); + try { + for (const path of [ + "~", + "~/hack-1357-secrets.enc.json", + "relative.json", + ]) { + await writeGlobalConfig({ + value: { + controlPlane: { + secrets: { backend: "encrypted_file", encryptedFile: { path } }, + }, + }, + }); + process.env.HOME = home; + const descriptor = await resolveSecretStoreDescriptor({ + projectName: "app", + }); + const location = descriptor.location; + expect(location.startsWith("/")).toBe(true); + expect( + location === process.cwd() || location.startsWith(`${process.cwd()}/`) + ).toBe(false); + expect(location.startsWith(homedir())).toBe(true); + process.env.HOME = tempDir ?? ""; + } + } finally { + restoreEnv("HACK_HOME", previousHackHome); + } + }); +} From eb30178ffc0ea8c7e322c91d027a3d5394a82872 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:03:18 -0500 Subject: [PATCH 05/19] fix: keep branch usage out of the tracked tree on next (HACK-1758) Forward main's 83d71ac7 (HACK-1351). Runtime commands in a linked worktree created .hack/hack.branches.json or rewrote its last_used_at on every up, down, restart, run, exec and logs, which dirtied git status. Usage now lives in machine-local .hack/.internal/branch-usage.json. The tracked registry is written only by `hack branch add` and `remove`, and legacy last_used_at values are read as a fallback and left byte-identical. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 +- docs/cli.md | 13 ++ src/commands/branch.ts | 55 ++++---- src/commands/project.ts | 21 +-- src/lib/branch-usage.ts | 143 ++++++++++++++++++++ src/lib/branches.ts | 70 ---------- src/lib/project-meta.ts | 8 +- src/templates.ts | 7 +- tests/branches.test.ts | 94 ++++++++++--- tests/e2e/fixture.ts | 21 +++ tests/e2e/scenarios/worktree-parallel-up.ts | 25 ++++ 11 files changed, 333 insertions(+), 128 deletions(-) create mode 100644 src/lib/branch-usage.ts diff --git a/docs/architecture.md b/docs/architecture.md index 70d6162da..33cafb813 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -390,7 +390,8 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl - `/.hack/` - `docker-compose.yml` - `hack.config.json` - - `hack.branches.json` (optional) + - `hack.branches.json` (optional committed branch registry; written only by `hack branch add`/ + `remove`, never by runtime commands) - `.gitignore` (committed; created by `init`, and by `doctor --fix` when an existing project lacks it or its managed block; runtime commands put the same rules in the repository's local `info/exclude` instead of writing it; covers machine-local generated files — @@ -406,6 +407,7 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl - `.internal/compose.env.override.yml` - `.internal/compose.runtime.override.yml` - `.internal/extra-hosts.json` + - `.internal/branch-usage.json` (machine-local branch `last_used_at`, written by runtime commands) - `.internal/lifecycle/state.json` and `.internal/lifecycle/*.log` - `.branch/compose..override.yml` - `.branch/compose..runtime.override.yml` diff --git a/docs/cli.md b/docs/cli.md index 12a19078c..a277f45b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -496,6 +496,19 @@ redirected inherited files fail explicitly instead of silently changing routing. These commands manage the Compose path; the experimental graph runner's direct Compose-input handling is a separate qualification boundary. +### Branch registry and usage + +Runtime commands never write tracked files. `hack up/down/restart/run/exec/logs` record when a +branch instance was last used in machine-local `.hack/.internal/branch-usage.json` (ignored by the +committed `.hack/.gitignore`, one per checkout), so `git status` stays clean in a linked worktree. + +`.hack/hack.branches.json` is the optional, committed branch registry. Only `hack branch add` and +`hack branch remove` create or change it, and they record identity (`name`, `slug`, `note`, +`created_at`) but no usage timestamp. Existing registries that already carry `last_used_at` are left +as they are: runtime commands stop rewriting them, and `hack branch list` and `hack projects` +show the machine-local time when this checkout has one, falling back to the committed value. You +can delete the old `last_used_at` fields by hand at any time. + ### Disposable cache volumes Compose preserves named volumes on ordinary `down`, which is correct for application data but can diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 771038496..30a9eb376 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -9,6 +9,12 @@ import { import { optOpenPreference, optPath, optProject } from "../cli/options.ts"; import { resolveRegisteredProjectOption } from "../cli/project-selector.ts"; import { DEFAULT_PROJECT_TLD } from "../constants.ts"; +import { + forgetBranchUsage, + mergeBranchUsage, + readBranchUsage, + recordBranchUsage, +} from "../lib/branch-usage.ts"; import { readBranchesFile, writeBranchesFile } from "../lib/branches.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { MissingProjectContextError } from "../lib/missing-project-error.ts"; @@ -124,36 +130,21 @@ const handleBranchAdd: CommandHandlerFor = async ({ return 1; } + // The tracked registry only records identity and creation time; volatile + // usage goes to machine-local state so the committed file stays stable. const file = { ...read.file }; const existingIndex = file.branches.findIndex((entry) => entry.slug === slug); + const existing = existingIndex >= 0 ? file.branches[existingIndex] : null; - if (existingIndex >= 0) { - const existing = file.branches[existingIndex]; - if (!existing) { - file.branches = [ - ...file.branches, - { - name: nameRaw, - slug, - ...(note ? { note } : {}), - created_at: nowIso, - last_used_at: nowIso, - }, - ]; - await writeBranchesFile({ path: read.path, file }); - logger.success({ message: `Added branch "${slug}"` }); - return 0; - } - const updated = { + if (existing) { + file.branches = [...file.branches]; + file.branches[existingIndex] = { ...existing, name: nameRaw, slug, ...(note ? { note } : {}), created_at: existing.created_at ?? nowIso, - last_used_at: nowIso, }; - file.branches = [...file.branches]; - file.branches[existingIndex] = updated; } else { file.branches = [ ...file.branches, @@ -162,17 +153,18 @@ const handleBranchAdd: CommandHandlerFor = async ({ slug, ...(note ? { note } : {}), created_at: nowIso, - last_used_at: nowIso, }, ]; } await writeBranchesFile({ path: read.path, file }); + await recordBranchUsage({ + projectDir: project.projectDir, + branch: slug, + nowIso, + }); logger.success({ - message: - existingIndex >= 0 - ? `Updated branch "${slug}"` - : `Added branch "${slug}"`, + message: existing ? `Updated branch "${slug}"` : `Added branch "${slug}"`, }); return 0; }; @@ -204,7 +196,8 @@ const handleBranchList: CommandHandlerFor = async ({ return 0; } - const rows = [...read.file.branches] + const usage = await readBranchUsage({ projectDir: project.projectDir }); + const rows = mergeBranchUsage({ branches: read.file.branches, usage }) .sort((a, b) => a.slug.localeCompare(b.slug)) .map((entry) => [ entry.name, @@ -264,6 +257,14 @@ const handleBranchRemove: CommandHandlerFor = async ({ path: read.path, file: { ...read.file, branches: next }, }); + for (const removed of read.file.branches) { + if (!next.includes(removed)) { + await forgetBranchUsage({ + projectDir: project.projectDir, + branch: removed.slug, + }); + } + } logger.success({ message: `Removed branch "${slug}".` }); return 0; }; diff --git a/src/commands/project.ts b/src/commands/project.ts index bbad3f114..72006e7e0 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -146,7 +146,8 @@ import { applyBranchToHosts, rewriteCaddyLabelForBranch, } from "../lib/branch-hosts.ts"; -import { resolveEffectiveBranch, touchBranchUsage } from "../lib/branches.ts"; +import { recordBranchUsage } from "../lib/branch-usage.ts"; +import { resolveEffectiveBranch } from "../lib/branches.ts"; import { type CliResult, emitCliResult, @@ -2796,17 +2797,17 @@ function resolveBranchAliasHost(opts: { }); } -async function touchBranchUsageIfNeeded(opts: { +async function recordBranchUsageIfNeeded(opts: { readonly project: Awaited>; readonly branch: string | null; }): Promise { if (!opts.branch) { return; } - const res = await touchBranchUsage({ + // Usage is machine-local state; never write the tracked hack.branches.json. + const res = await recordBranchUsage({ projectDir: opts.project.projectDir, branch: opts.branch, - createIfMissing: true, }); if (res.error) { logger.warn({ @@ -5378,7 +5379,7 @@ async function runUpCommand({ }); } - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const remoteUpCode = await runRemoteLifecycleCommand({ project, action: "up", @@ -6052,7 +6053,7 @@ async function runDownCommand({ requestedTarget: args.options.target, }); if (remoteDownCode !== null) { - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); if (json) { return emitRemoteLifecycleJson({ action: "down", @@ -6097,7 +6098,7 @@ async function runDownCommand({ }, }); } - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; const cachePruneCandidates = pruneCaches && runtime @@ -6917,7 +6918,7 @@ async function runRestartCommand({ }); const profiles = parseCsvList(args.options.profile); - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const remoteRestartCode = await runRemoteLifecycleCommand({ project, action: "restart", @@ -7610,7 +7611,7 @@ async function handleRun({ envOption: args.options.env, }); - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const service = (args.positionals.service ?? "").trim(); if (service.length === 0) { throw new CliUsageError("Missing required argument: service"); @@ -7789,7 +7790,7 @@ async function handleExec({ envOption: args.options.env, }); - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const service = (args.positionals.service ?? "").trim(); if (service.length === 0) { throw new CliUsageError("Missing required argument: service"); diff --git a/src/lib/branch-usage.ts b/src/lib/branch-usage.ts new file mode 100644 index 000000000..f0d7b9079 --- /dev/null +++ b/src/lib/branch-usage.ts @@ -0,0 +1,143 @@ +import { resolve } from "node:path"; +import type { BranchEntry } from "./branches.ts"; +import { ensureDir, readTextFile, writeTextFileIfChanged } from "./fs.ts"; +import { getString, isRecord } from "./guards.ts"; + +/** + * Machine-local branch usage, kept under `.hack/.internal/` so runtime + * commands (up/down/restart/run/exec/logs) never write a tracked file. + * + * The committed `hack.branches.json` only holds the explicit registry + * (`hack branch add/remove`). Its legacy `last_used_at` values are read as a + * fallback and never rewritten by runtime commands. Each checkout (primary or + * linked worktree) has its own `.hack/.internal/`, so usage is naturally + * keyed by checkout. + */ + +export const BRANCH_USAGE_VERSION = 1 as const; +const BRANCH_USAGE_FILENAME = "branch-usage.json" as const; + +export type BranchUsageEntry = { + readonly last_used_at: string; +}; + +type BranchUsageFile = { + readonly version: typeof BRANCH_USAGE_VERSION; + readonly branches: Readonly>; +}; + +export type RecordBranchUsageResult = { + readonly path: string; + readonly error?: string; +}; + +export function resolveBranchUsagePath(opts: { + readonly projectDir: string; +}): string { + return resolve(opts.projectDir, ".internal", BRANCH_USAGE_FILENAME); +} + +/** Reads machine-local usage; a missing or corrupt file yields no entries. */ +export async function readBranchUsage(opts: { + readonly projectDir: string; +}): Promise> { + const text = await readTextFile(resolveBranchUsagePath(opts)); + if (text === null) { + return new Map(); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return new Map(); + } + return parseBranchUsage(parsed); +} + +/** Records that a branch instance was just used by a runtime command. */ +export async function recordBranchUsage(opts: { + readonly projectDir: string; + readonly branch: string; + readonly nowIso?: string; +}): Promise { + const path = resolveBranchUsagePath(opts); + try { + const current = await readBranchUsage(opts); + const next = new Map(current); + next.set(opts.branch, { + last_used_at: opts.nowIso ?? new Date().toISOString(), + }); + await writeBranchUsage({ projectDir: opts.projectDir, usage: next }); + return { path }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { path, error: message }; + } +} + +/** Drops machine-local usage for a branch removed from the registry. */ +export async function forgetBranchUsage(opts: { + readonly projectDir: string; + readonly branch: string; +}): Promise { + const current = await readBranchUsage(opts); + if (!current.has(opts.branch)) { + return; + } + const next = new Map(current); + next.delete(opts.branch); + await writeBranchUsage({ projectDir: opts.projectDir, usage: next }); +} + +/** + * Overlays machine-local `last_used_at` onto registry entries; legacy + * tracked values remain as a fallback when this checkout has no record. + */ +export function mergeBranchUsage(opts: { + readonly branches: readonly BranchEntry[]; + readonly usage: ReadonlyMap; +}): BranchEntry[] { + return opts.branches.map((entry) => { + const local = opts.usage.get(entry.slug); + return local ? { ...entry, last_used_at: local.last_used_at } : entry; + }); +} + +async function writeBranchUsage(opts: { + readonly projectDir: string; + readonly usage: ReadonlyMap; +}): Promise { + const branches: Record = {}; + for (const [slug, entry] of [...opts.usage].sort(([a], [b]) => + a.localeCompare(b) + )) { + branches[slug] = entry; + } + const file: BranchUsageFile = { version: BRANCH_USAGE_VERSION, branches }; + await ensureDir(resolve(opts.projectDir, ".internal")); + await writeTextFileIfChanged( + resolveBranchUsagePath(opts), + `${JSON.stringify(file, null, 2)}\n` + ); +} + +function parseBranchUsage(value: unknown): Map { + const out = new Map(); + if (!isRecord(value) || value.version !== BRANCH_USAGE_VERSION) { + return out; + } + const branches = value.branches; + if (!isRecord(branches)) { + return out; + } + for (const [slug, entry] of Object.entries(branches)) { + if (!isRecord(entry)) { + continue; + } + const lastUsedAt = getString(entry, "last_used_at"); + if (lastUsedAt) { + out.set(slug, { last_used_at: lastUsedAt }); + } + } + return out; +} diff --git a/src/lib/branches.ts b/src/lib/branches.ts index 1a858db36..0ad089fa8 100644 --- a/src/lib/branches.ts +++ b/src/lib/branches.ts @@ -36,13 +36,6 @@ export type BranchesReadResult = { readonly parseError?: string; }; -export type TouchBranchUsageResult = { - readonly updated: boolean; - readonly created: boolean; - readonly path: string; - readonly error?: string; -}; - export async function readBranchesFile(opts: { readonly projectDir: string; }): Promise { @@ -97,69 +90,6 @@ export async function writeBranchesFile(opts: { await writeTextFileIfChanged(opts.path, `${JSON.stringify(out, null, 2)}\n`); } -export async function touchBranchUsage(opts: { - readonly projectDir: string; - readonly branch: string; - readonly nowIso?: string; - readonly createIfMissing?: boolean; -}): Promise { - const nowIso = opts.nowIso ?? new Date().toISOString(); - const read = await readBranchesFile({ projectDir: opts.projectDir }); - if (read.parseError) { - return { - updated: false, - created: false, - path: read.path, - error: read.parseError, - }; - } - - if (!(read.exists || opts.createIfMissing)) { - return { updated: false, created: false, path: read.path }; - } - - let updated = false; - let created = false; - let matched = false; - - const nextBranches = read.file.branches.map((entry) => { - if ( - entry.slug !== opts.branch && - entry.name.toLowerCase() !== opts.branch.toLowerCase() - ) { - return entry; - } - matched = true; - updated = true; - return { - ...entry, - created_at: entry.created_at ?? nowIso, - last_used_at: nowIso, - }; - }); - - if (!matched && opts.createIfMissing) { - updated = true; - created = true; - nextBranches.push({ - name: opts.branch, - slug: opts.branch, - created_at: nowIso, - last_used_at: nowIso, - }); - } - - if (!updated) { - return { updated: false, created: false, path: read.path }; - } - - await writeBranchesFile({ - path: read.path, - file: { ...read.file, branches: nextBranches }, - }); - return { updated: true, created, path: read.path }; -} - export type EffectiveBranchSource = | "explicit" | "worktree" diff --git a/src/lib/project-meta.ts b/src/lib/project-meta.ts index bc153cb8a..c79c07590 100644 --- a/src/lib/project-meta.ts +++ b/src/lib/project-meta.ts @@ -3,6 +3,7 @@ import { YAML } from "bun"; import type { MuxSession } from "../mux/mux-backend.ts"; import { getMuxBackends } from "../mux/mux-resolver.ts"; import { parseSessionBase } from "../mux/session-names.ts"; +import { mergeBranchUsage, readBranchUsage } from "./branch-usage.ts"; import type { BranchEntry } from "./branches.ts"; import { readBranchesFile } from "./branches.ts"; import { pathExists, readTextFile } from "./fs.ts"; @@ -274,11 +275,14 @@ function parseGitWorktreePorcelain(text: string): readonly GitWorktreeMeta[] { export async function resolveHackBranchesMeta(opts: { readonly projectDir: string; }): Promise { - const read = await readBranchesFile({ projectDir: opts.projectDir }); + const [read, usage] = await Promise.all([ + readBranchesFile({ projectDir: opts.projectDir }), + readBranchUsage({ projectDir: opts.projectDir }), + ]); return { path: read.path, parseError: read.parseError ?? null, - branches: read.file.branches, + branches: mergeBranchUsage({ branches: read.file.branches, usage }), }; } diff --git a/src/templates.ts b/src/templates.ts index c5659359a..8bdb81d5e 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1081,7 +1081,12 @@ export function renderProjectBranchesSchemaJson(): string { }, note: { type: "string" }, created_at: { type: "string", format: "date-time" }, - last_used_at: { type: "string", format: "date-time" }, + last_used_at: { + type: "string", + format: "date-time", + description: + "Deprecated: read-only legacy value. Usage is now machine-local in .hack/.internal/branch-usage.json.", + }, }, }, }, diff --git a/tests/branches.test.ts b/tests/branches.test.ts index 99858b1ef..8328feb33 100644 --- a/tests/branches.test.ts +++ b/tests/branches.test.ts @@ -4,10 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PROJECT_BRANCHES_FILENAME } from "../src/constants.ts"; import { - readBranchesFile, - touchBranchUsage, - writeBranchesFile, -} from "../src/lib/branches.ts"; + forgetBranchUsage, + mergeBranchUsage, + readBranchUsage, + recordBranchUsage, + resolveBranchUsagePath, +} from "../src/lib/branch-usage.ts"; +import { readBranchesFile, writeBranchesFile } from "../src/lib/branches.ts"; import { ensureDir, pathExists } from "../src/lib/fs.ts"; import { sanitizeBranchSlug } from "../src/lib/project.ts"; @@ -25,7 +28,27 @@ test("sanitizeBranchSlug normalizes separators and strips invalid chars", () => expect(sanitizeBranchSlug(" ")).toBe(""); }); -test("touchBranchUsage updates last_used_at when branch entry exists", async () => { +test("recordBranchUsage writes machine-local state, never hack.branches.json", async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-branches-")); + const projectDir = join(tempDir, ".hack"); + await ensureDir(projectDir); + + const res = await recordBranchUsage({ + projectDir, + branch: "feature-x", + nowIso: "2025-01-02T03:04:05Z", + }); + expect(res.error).toBeUndefined(); + expect(res.path).toBe(join(projectDir, ".internal", "branch-usage.json")); + expect(await pathExists(join(projectDir, PROJECT_BRANCHES_FILENAME))).toBe( + false + ); + + const usage = await readBranchUsage({ projectDir }); + expect(usage.get("feature-x")?.last_used_at).toBe("2025-01-02T03:04:05Z"); +}); + +test("recordBranchUsage leaves an existing tracked registry byte-identical", async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-branches-")); const projectDir = join(tempDir, ".hack"); await ensureDir(projectDir); @@ -40,33 +63,70 @@ test("touchBranchUsage updates last_used_at when branch entry exists", async () name: "feature-x", slug: "feature-x", created_at: "2025-01-01T00:00:00Z", + last_used_at: "2025-01-01T00:00:00Z", }, ], }, }); + const before = await Bun.file(path).text(); - const res = await touchBranchUsage({ + await recordBranchUsage({ projectDir, branch: "feature-x", nowIso: "2025-01-02T03:04:05Z", }); - expect(res.updated).toBe(true); + expect(await Bun.file(path).text()).toBe(before); const read = await readBranchesFile({ projectDir }); - expect(read.file.branches[0]?.last_used_at).toBe("2025-01-02T03:04:05Z"); + const merged = mergeBranchUsage({ + branches: read.file.branches, + usage: await readBranchUsage({ projectDir }), + }); + expect(merged[0]?.last_used_at).toBe("2025-01-02T03:04:05Z"); }); -test("touchBranchUsage does not create file when missing", async () => { +test("mergeBranchUsage falls back to the legacy tracked value", () => { + const merged = mergeBranchUsage({ + branches: [ + { name: "a", slug: "a", last_used_at: "2025-01-01T00:00:00Z" }, + { name: "b", slug: "b" }, + ], + usage: new Map([["b", { last_used_at: "2025-02-02T00:00:00Z" }]]), + }); + expect(merged.map((entry) => entry.last_used_at)).toEqual([ + "2025-01-01T00:00:00Z", + "2025-02-02T00:00:00Z", + ]); +}); + +test("forgetBranchUsage drops one entry and keeps the rest", async () => { tempDir = await mkdtemp(join(tmpdir(), "hack-branches-")); const projectDir = join(tempDir, ".hack"); - await ensureDir(projectDir); - - const res = await touchBranchUsage({ + await recordBranchUsage({ projectDir, - branch: "feature-x", + branch: "a", + nowIso: "2025-01-01T00:00:00Z", }); - expect(res.updated).toBe(false); - expect(await pathExists(join(projectDir, PROJECT_BRANCHES_FILENAME))).toBe( - false - ); + await recordBranchUsage({ + projectDir, + branch: "b", + nowIso: "2025-01-01T00:00:00Z", + }); + + await forgetBranchUsage({ projectDir, branch: "a" }); + + const usage = await readBranchUsage({ projectDir }); + expect([...usage.keys()]).toEqual(["b"]); +}); + +test("readBranchUsage tolerates a corrupt machine-local file", async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-branches-")); + const projectDir = join(tempDir, ".hack"); + await ensureDir(join(projectDir, ".internal")); + await Bun.write(resolveBranchUsagePath({ projectDir }), "{not json"); + + expect((await readBranchUsage({ projectDir })).size).toBe(0); + const res = await recordBranchUsage({ projectDir, branch: "a" }); + expect(res.error).toBeUndefined(); + expect((await readBranchUsage({ projectDir })).has("a")).toBe(true); }); diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index 35583a0a8..5f6009f9d 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -2,6 +2,11 @@ import type { Stats } from "node:fs"; import { lstat, mkdir, readdir, readlink } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; +import { + HACK_DIR_GITIGNORE_BEGIN_MARKER, + HACK_DIR_GITIGNORE_END_MARKER, + HACK_DIR_GITIGNORE_ENTRIES, +} from "../../src/templates.ts"; import { runCommand } from "./harness.ts"; /** @@ -63,6 +68,11 @@ export async function createMonorepoFixture(opts: { readonly name?: string; readonly lifecycle?: LifecycleFixtureOptions; readonly oauthEnabled?: boolean; + /** + * Also commit the canonical `.hack/.gitignore` that `hack init` writes, so + * scenarios can assert that runtime commands leave `git status` clean. + */ + readonly withHackGitignore?: boolean; }): Promise { const name = opts.name ?? randomProjectName(); const devHost = `${name}.hack`; @@ -92,6 +102,17 @@ export async function createMonorepoFixture(opts: { lifecycle: opts.lifecycle, oauthEnabled: opts.oauthEnabled, }); + if (opts.withHackGitignore === true) { + await Bun.write( + join(hackDir, ".gitignore"), + [ + HACK_DIR_GITIGNORE_BEGIN_MARKER, + ...HACK_DIR_GITIGNORE_ENTRIES, + HACK_DIR_GITIGNORE_END_MARKER, + "", + ].join("\n") + ); + } } await gitInitAndCommit({ root }); diff --git a/tests/e2e/scenarios/worktree-parallel-up.ts b/tests/e2e/scenarios/worktree-parallel-up.ts index 74de91a1d..9c49c7a72 100644 --- a/tests/e2e/scenarios/worktree-parallel-up.ts +++ b/tests/e2e/scenarios/worktree-parallel-up.ts @@ -37,6 +37,7 @@ export const worktreeParallelUpScenario: Scenario = { const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: true, + withHackGitignore: true, }); const worktreePath = await addLinkedWorktree({ fixture, branch: BRANCH }); @@ -63,6 +64,8 @@ export const worktreeParallelUpScenario: Scenario = { message: "hack up --detach in the linked worktree (branch instance) should succeed", }); + await expectCleanGitStatus({ cwd: worktreePath, after: "worktree up" }); + await expectCleanGitStatus({ cwd: fixture.root, after: "worktree up" }); const primaryUrl = await resolveOpenUrl({ ctx, cwd: fixture.root }); const worktreeUrl = await resolveOpenUrl({ ctx, cwd: worktreePath }); @@ -87,6 +90,7 @@ export const worktreeParallelUpScenario: Scenario = { ctx, cwd: worktreePath, }); + await expectCleanGitStatus({ cwd: worktreePath, after: "worktree exec" }); expect({ that: primaryMetadata?.branch === null && @@ -196,6 +200,7 @@ export const worktreeParallelUpScenario: Scenario = { codes: [0], message: "hack down in the worktree should stop the branch instance", }); + await expectCleanGitStatus({ cwd: worktreePath, after: "worktree down" }); const downPrimary = await ctx.cli({ args: ["down"], cwd: fixture.root, @@ -212,6 +217,26 @@ export const worktreeParallelUpScenario: Scenario = { }, }; +/** + * Runtime commands must keep volatile state (branch usage timestamps, + * generated compose files) out of the tracked tree: a linked worktree's + * `git status` stays empty across `up`, `exec` and `down` (HACK-1351). + */ +async function expectCleanGitStatus(opts: { + readonly cwd: string; + readonly after: string; +}): Promise { + const status = await runCommand({ + argv: ["git", "status", "--porcelain", "--untracked-files=all"], + cwd: opts.cwd, + }); + expect({ + that: status.exitCode === 0 && status.stdout.trim().length === 0, + message: `git status --porcelain in ${opts.cwd} should be empty after ${opts.after}`, + result: status, + }); +} + async function readContainerIds(opts: { readonly cwd: string; readonly composeProject: string; From 8a79897accbd5c9c9e090f4e1b719985e491ac2a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:04:38 -0500 Subject: [PATCH 06/19] test(e2e): fail and sweep on leaked fixture Docker networks on next (HACK-1758) Forward main's 65e4d97b (HACK-1346). Fixture names are recorded as owned in memory and in a per-PID journal. A Docker scenario that leaves an owned network now fails, the harness sweeps by exact ID and never prunes, run.ts sweeps on SIGINT, SIGTERM and SIGHUP and reclaims journals from dead runs, and downBestEffort uses the real `--` project name. Adapted for next: run.ts keeps its launchd canary and import.meta.main guard, and the new test restores its env var for next's state guard. Co-Authored-By: Claude Opus 5.5 --- tests/e2e-docker-ownership.test.ts | 342 +++++++++++++++++++++++++ tests/e2e/README.md | 18 +- tests/e2e/docker-ownership.ts | 369 +++++++++++++++++++++++++++ tests/e2e/fixture.ts | 13 +- tests/e2e/harness.ts | 127 +++++++++ tests/e2e/run.ts | 109 +++++++- tests/e2e/scenarios/docker-shared.ts | 9 +- 7 files changed, 979 insertions(+), 8 deletions(-) create mode 100644 tests/e2e-docker-ownership.test.ts create mode 100644 tests/e2e/docker-ownership.ts diff --git a/tests/e2e-docker-ownership.test.ts b/tests/e2e-docker-ownership.test.ts new file mode 100644 index 000000000..51ff8dcf2 --- /dev/null +++ b/tests/e2e-docker-ownership.test.ts @@ -0,0 +1,342 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { readdir, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveLifecycleComposeProjectName } from "../src/lib/lifecycle-runtime.ts"; + +const journalDir = mkdtempSync(join(tmpdir(), "hack-e2e-ownership-test-")); +const originalOwnershipDir = process.env.HACK_E2E_OWNERSHIP_DIR; +process.env.HACK_E2E_OWNERSHIP_DIR = journalDir; + +const { + branchComposeProject, + isOwnedComposeProject, + recordOwnedFixture, + recoverStaleOwnership, + settleRunOwnership, + sweepOwnedComposeResources, +} = await import("./e2e/docker-ownership.ts"); +const { checkScenarioDockerLeaks, describeDockerLeak, runScenarios } = + await import("./e2e/harness.ts"); + +import type { DockerRunner } from "./e2e/docker-ownership.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +afterAll(async () => { + restoreEnv("HACK_E2E_OWNERSHIP_DIR", originalOwnershipDir); + await rm(journalDir, { recursive: true, force: true }); +}); + +type Kind = "container" | "network" | "volume"; +type FakeResource = { kind: Kind; id: string; name: string; project: string }; + +/** + * In-memory docker that understands the ls/ps and rm calls the harness makes. + * It renders the requested --format template like the real CLI, including + * rejecting `{{.ID}}` for volumes (volumes have no ID; they are removed by + * name). `available: false` simulates an unreachable daemon. + */ +function fakeDocker( + initial: FakeResource[], + options: { available?: boolean } = {} +): { + docker: DockerRunner; + resources: FakeResource[]; + calls: string[][]; +} { + const resources = [...initial]; + const calls: string[][] = []; + const unavailable = { + exitCode: 1, + stdout: "", + stderr: "Cannot connect to the Docker daemon", + }; + const list = (kind: Kind, format: string) => { + if (kind === "volume" && format.includes("{{.ID}}")) { + return { + exitCode: 1, + stdout: "", + stderr: + 'template: :1:2: executing "" at <.ID>: can\'t evaluate field ID', + }; + } + return { + exitCode: 0, + stdout: resources + .filter((resource) => resource.kind === kind) + .map((resource) => + format + .replace("{{.ID}}", resource.id) + .replaceAll(/\{\{\.Names?\}\}/g, resource.name) + .replace(/\{\{\.Label "[^"]+"\}\}/, resource.project) + ) + .join("\n"), + stderr: "", + }; + }; + const remove = (kind: Kind, ref: string) => { + const index = resources.findIndex( + (resource) => + resource.kind === kind && + (kind === "volume" ? resource.name : resource.id) === ref + ); + if (index < 0) { + return { exitCode: 1, stdout: "", stderr: `no such ${kind}` }; + } + resources.splice(index, 1); + return { exitCode: 0, stdout: ref, stderr: "" }; + }; + const docker: DockerRunner = async (args) => { + calls.push([...args]); + if (options.available === false) { + return unavailable; + } + const format = args[args.indexOf("--format") + 1] ?? ""; + if (args[0] === "ps") { + return list("container", format); + } + if (args[0] === "rm") { + return remove("container", args.at(-1) ?? ""); + } + const kind = args[0] as Kind; + return args[1] === "ls" ? list(kind, format) : remove(kind, args[2] ?? ""); + }; + return { docker, resources, calls }; +} + +function network(project: string): FakeResource { + return { + kind: "network", + id: `net-${project}`, + name: `${project}_default`, + project, + }; +} + +describe("e2e docker ownership", () => { + test("branch instances use the CLI's compose project naming", () => { + expect(branchComposeProject({ name: "e2e-abc", branch: "feat" })).toBe( + resolveLifecycleComposeProjectName({ + projectName: "e2e-abc", + branch: "feat", + }) + ); + }); + + test("owns recorded names and their branches only", () => { + const names = new Set(["e2e-abc1"]); + expect(isOwnedComposeProject({ project: "e2e-abc1", names })).toBe(true); + expect(isOwnedComposeProject({ project: "e2e-abc1--feat", names })).toBe( + true + ); + expect(isOwnedComposeProject({ project: "e2e-abc12", names })).toBe(false); + expect(isOwnedComposeProject({ project: "e2e-abc12--x", names })).toBe( + false + ); + expect(isOwnedComposeProject({ project: "e2e-ffff", names })).toBe(false); + expect(isOwnedComposeProject({ project: "mhof_default", names })).toBe( + false + ); + }); + + test("sweep removes owned containers, networks and volumes only", async () => { + const fake = fakeDocker([ + { + kind: "container", + id: "c1", + name: "e2e-abc1-web-1", + project: "e2e-abc1", + }, + network("e2e-abc1"), + network("e2e-abc1--feat"), + { kind: "volume", id: "v1", name: "e2e-abc1_data", project: "e2e-abc1" }, + network("e2e-other"), + network("mhof"), + ]); + const result = await sweepOwnedComposeResources({ + docker: fake.docker, + names: new Set(["e2e-abc1"]), + }); + expect(result.failed).toEqual([]); + expect(result.unlisted).toEqual([]); + expect(result.removed.map((resource) => resource.id)).toEqual([ + "c1", + "net-e2e-abc1", + "net-e2e-abc1--feat", + "e2e-abc1_data", + ]); + expect(fake.resources.map((resource) => resource.project)).toEqual([ + "e2e-other", + "mhof", + ]); + expect(fake.calls.some((call) => call.includes("prune"))).toBe(false); + }); + + test("leak check reports owned leftovers and verifies the sweep", async () => { + const fake = fakeDocker([network("e2e-abc1--feat"), network("e2e-other")]); + const check = await checkScenarioDockerLeaks({ + docker: fake.docker, + names: new Set(["e2e-abc1"]), + }); + expect(check?.leaked.map((resource) => resource.project)).toEqual([ + "e2e-abc1--feat", + ]); + expect(check?.remaining).toEqual([]); + expect(check && describeDockerLeak(check)).toContain( + "scenario left 1 Docker network(s)" + ); + expect(fake.resources.map((resource) => resource.project)).toEqual([ + "e2e-other", + ]); + }); + + test("a clean scenario passes the leak check", async () => { + const fake = fakeDocker([network("e2e-other")]); + const check = await checkScenarioDockerLeaks({ + docker: fake.docker, + names: new Set(["e2e-abc1"]), + }); + expect(check && describeDockerLeak(check)).toBeNull(); + }); + + test("runScenarios fails a docker scenario that leaves a network", async () => { + const fake = fakeDocker([network("mhof")]); + const outcomes = await runScenarios({ + dockerEnabled: true, + docker: fake.docker, + scenarios: [ + { + name: "leaky", + tier: "docker", + summary: "creates a network and forgets it", + run: async () => { + recordOwnedFixture({ name: "e2e-1eaced" }); + fake.resources.push(network("e2e-1eaced--feat")); + }, + }, + { + name: "tidy", + tier: "docker", + summary: "creates and removes its network", + run: async () => { + recordOwnedFixture({ name: "e2e-71d1" }); + fake.resources.push(network("e2e-71d1")); + fake.resources.pop(); + }, + }, + ], + }); + expect(outcomes.map((outcome) => outcome.status)).toEqual(["fail", "pass"]); + expect(outcomes[0]?.reason).toContain("e2e-1eaced--feat_default"); + expect(fake.resources.map((resource) => resource.project)).toEqual([ + "mhof", + ]); + }); + + test("reclaims a dead run's journal and leaves live runs alone", async () => { + const dir = join(journalDir, "recover"); + process.env.HACK_E2E_OWNERSHIP_DIR = dir; + try { + await Bun.write( + join(dir, "111.json"), + JSON.stringify({ pid: 111, names: ["e2e-dead"] }) + ); + await Bun.write( + join(dir, "222.json"), + JSON.stringify({ pid: 222, names: ["e2e-live"] }) + ); + const fake = fakeDocker([network("e2e-dead"), network("e2e-live")]); + await recoverStaleOwnership({ + docker: fake.docker, + isAlive: (pid) => pid === 222, + }); + expect(fake.resources.map((resource) => resource.project)).toEqual([ + "e2e-live", + ]); + expect((await readdir(dir)).sort()).toEqual(["222.json"]); + } finally { + process.env.HACK_E2E_OWNERSHIP_DIR = journalDir; + } + }); + + test("keeps a dead run's journal when docker cannot be listed", async () => { + const dir = join(journalDir, "unavailable"); + process.env.HACK_E2E_OWNERSHIP_DIR = dir; + try { + await Bun.write( + join(dir, "333.json"), + JSON.stringify({ pid: 333, names: ["e2e-dead"] }) + ); + const { recovered } = await recoverStaleOwnership({ + docker: fakeDocker([], { available: false }).docker, + isAlive: () => false, + }); + expect(recovered[0]?.unlisted).toEqual([ + "container", + "network", + "volume", + ]); + expect(await readdir(dir)).toEqual(["333.json"]); + } finally { + process.env.HACK_E2E_OWNERSHIP_DIR = journalDir; + } + }); + + test("end of run keeps the journal until the full sweep succeeds", async () => { + recordOwnedFixture({ name: "e2e-5e771e" }); + const journal = join(journalDir, `${process.pid}.json`); + const volume: FakeResource = { + kind: "volume", + id: "e2e-5e771e_data", + name: "e2e-5e771e_data", + project: "e2e-5e771e", + }; + const busy = fakeDocker([volume]); + const realDocker = busy.docker; + const refuseVolumeRm: DockerRunner = async (args) => + args[0] === "volume" && args[1] === "rm" + ? { exitCode: 1, stdout: "", stderr: "volume is in use" } + : await realDocker(args); + const first = await settleRunOwnership({ docker: refuseVolumeRm }); + expect(first.journalCleared).toBe(false); + expect(first.sweep.failed.map((failure) => failure.resource.name)).toEqual([ + "e2e-5e771e_data", + ]); + expect(await Bun.file(journal).exists()).toBe(true); + + const down = await settleRunOwnership({ + docker: fakeDocker([volume], { available: false }).docker, + }); + expect(down.journalCleared).toBe(false); + expect(await Bun.file(journal).exists()).toBe(true); + + const second = await settleRunOwnership({ docker: realDocker }); + expect(second.journalCleared).toBe(true); + expect(busy.resources).toEqual([]); + expect(await Bun.file(journal).exists()).toBe(false); + }); + + test("recovery skips a journal removed after it was listed", async () => { + const dir = join(journalDir, "vanishing"); + process.env.HACK_E2E_OWNERSHIP_DIR = dir; + try { + await Bun.write( + join(dir, "444.json"), + JSON.stringify({ pid: 444, names: ["e2e-dead"] }) + ); + // A dangling entry reads like a journal another run deleted mid-scan. + await symlink(join(dir, "gone.json.missing"), join(dir, "555.json")); + const fake = fakeDocker([network("e2e-dead")]); + const { recovered } = await recoverStaleOwnership({ + docker: fake.docker, + isAlive: () => false, + }); + expect(recovered).toHaveLength(1); + expect(fake.resources).toEqual([]); + } finally { + process.env.HACK_E2E_OWNERSHIP_DIR = journalDir; + } + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 3019b8ba0..63f28d2d7 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -743,7 +743,23 @@ control, an explicit `setup agents --agents-md`, must change exactly removed in a `finally` (skip with `HACK_E2E_KEEP=1`). - Docker scenarios run `hack down` (primary + branch instances) AND a raw `docker compose -p down --volumes --remove-orphans` sweep in - their own `finally`, keyed by the random fixture name. + their own `finally`, keyed by the random fixture name (`` and + `--`, the CLI's branch-instance project name). +- Every fixture name the run generates is recorded as owned + (`docker-ownership.ts`), with a per-process journal under + `$TMPDIR/hack-e2e-ownership/` (override: `HACK_E2E_OWNERSHIP_DIR`). Only + Compose resources whose `com.docker.compose.project` label is an owned + name, or `--`, are ever removed; nothing is pruned, and + fixtures from other concurrent runs are left alone. +- Network leak check: after each docker scenario the harness lists the + scenario's owned networks. Any network left after the scenario's own + cleanup FAILS the scenario; the harness then removes the scenario's owned + containers, networks and volumes and verifies the networks are gone. The + run prints `fixture networks: before=N after=M left by this run=K` and + exits 1 if `K > 0`. +- Interrupts: SIGINT/SIGTERM/SIGHUP sweep the run's owned resources before + exiting. A run killed with SIGKILL leaves its journal behind; the next + docker run reclaims the journal's resources once that process is gone. - The canary restores the real registry snapshot best-effort if a leak is ever detected (and aborts the run). diff --git a/tests/e2e/docker-ownership.ts b/tests/e2e/docker-ownership.ts new file mode 100644 index 000000000..da9dceecf --- /dev/null +++ b/tests/e2e/docker-ownership.ts @@ -0,0 +1,369 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdir, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Docker ownership for e2e fixtures. + * + * Every fixture project name created by this process is recorded here (and in + * a per-process journal on disk). That record is the ownership proof for + * cleanup: the harness only removes Compose containers, networks and volumes + * whose `com.docker.compose.project` label is a recorded name or one of its + * branch instances (`--`). Unrelated projects, and e2e fixtures + * from other concurrent runs, are never touched. + * + * The journal lets a later run reclaim resources from a run that was + * SIGKILLed before its own cleanup could run: a journal whose process is no + * longer alive is swept and then deleted. + */ + +/** Compose label carrying the project name on containers, networks, volumes. */ +export const COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; + +/** Project names produced by the e2e fixtures (`e2e-[--]`). */ +export const E2E_FIXTURE_PROJECT_PATTERN = /^e2e-[0-9a-f]+(?:--.+)?$/; + +const DOCKER_TIMEOUT_MS = 60_000; + +export type DockerResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +/** Runs `docker `; injectable so the cleanup logic is unit-testable. */ +export type DockerRunner = (args: readonly string[]) => Promise; + +export type ComposeResource = { + readonly kind: "container" | "network" | "volume"; + readonly id: string; + readonly name: string; + readonly project: string; +}; + +export type SweepResult = { + readonly removed: readonly ComposeResource[]; + readonly failed: readonly { + readonly resource: ComposeResource; + readonly error: string; + }[]; + /** Resource kinds that could not be listed, so were not checked at all. */ + readonly unlisted: readonly ComposeResource["kind"][]; +}; + +/** True when the sweep checked every kind and removed everything it owned. */ +export function sweepComplete(sweep: SweepResult): boolean { + return sweep.failed.length === 0 && sweep.unlisted.length === 0; +} + +/** Default runner: spawn the docker CLI with a bounded timeout. */ +export const runDocker: DockerRunner = async (args) => { + const proc = Bun.spawn(["docker", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => proc.kill(), DOCKER_TIMEOUT_MS); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + clearTimeout(timer); + return { exitCode, stdout, stderr }; +}; + +/** Compose project of a branch instance, matching the CLI's naming. */ +export function branchComposeProject(opts: { + readonly name: string; + readonly branch: string; +}): string { + return `${opts.name}--${opts.branch}`; +} + +/** True when `project` is a recorded fixture name or one of its branches. */ +export function isOwnedComposeProject(opts: { + readonly project: string; + readonly names: ReadonlySet; +}): boolean { + if (opts.names.has(opts.project)) { + return true; + } + const separator = opts.project.indexOf("--"); + return separator > 0 && opts.names.has(opts.project.slice(0, separator)); +} + +const recordedNames = new Set(); +let journalPath: string | null = null; + +/** Directory holding per-process ownership journals. */ +export function ownershipJournalDir(): string { + const override = process.env.HACK_E2E_OWNERSHIP_DIR?.trim(); + return override && override.length > 0 + ? override + : join(tmpdir(), "hack-e2e-ownership"); +} + +/** + * Record a fixture project name as owned by this process. Synchronous so the + * journal is on disk before any `hack up` can create resources for it. + */ +export function recordOwnedFixture(opts: { readonly name: string }): void { + if (recordedNames.has(opts.name)) { + return; + } + recordedNames.add(opts.name); + writeJournal(); +} + +/** Snapshot of every fixture name recorded by this process. */ +export function ownedFixtureNames(): ReadonlySet { + return new Set(recordedNames); +} + +function writeJournal(): void { + const dir = ownershipJournalDir(); + journalPath ??= join(dir, `${process.pid}.json`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + journalPath, + `${JSON.stringify({ pid: process.pid, names: [...recordedNames] })}\n` + ); +} + +/** Remove this process's journal once its resources are verified gone. */ +export async function clearOwnershipJournal(): Promise { + if (journalPath !== null) { + await rm(journalPath, { force: true }); + } +} + +/** + * End of run: sweep everything this process owns and clear its journal only + * when the sweep listed every resource kind and every removal succeeded. + */ +export async function settleRunOwnership(opts: { + readonly docker: DockerRunner; +}): Promise<{ readonly sweep: SweepResult; readonly journalCleared: boolean }> { + const sweep = await sweepOwnedComposeResources({ + docker: opts.docker, + names: ownedFixtureNames(), + }); + const journalCleared = sweepComplete(sweep); + if (journalCleared) { + await clearOwnershipJournal(); + } + return { sweep, journalCleared }; +} + +/** + * List Compose-labelled resources of one kind. Returns null when docker is + * unavailable, so callers can distinguish "none" from "could not look". + */ +export async function listComposeResources(opts: { + readonly docker: DockerRunner; + readonly kind: ComposeResource["kind"]; +}): Promise { + // Volumes have no ID placeholder; their name is the removal identifier. + const identity = { + container: "{{.ID}}\t{{.Names}}", + network: "{{.ID}}\t{{.Name}}", + volume: "{{.Name}}\t{{.Name}}", + }[opts.kind]; + const format = `${identity}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`; + const args = + opts.kind === "container" + ? ["ps", "-a", "--format", format] + : [opts.kind, "ls", "--format", format]; + const result = await opts.docker([ + ...args, + "--filter", + `label=${COMPOSE_PROJECT_LABEL}`, + ]); + if (result.exitCode !== 0) { + return null; + } + const resources: ComposeResource[] = []; + for (const line of result.stdout.split("\n")) { + const [id, name, project] = line.trim().split("\t"); + if (id && name && project) { + resources.push({ kind: opts.kind, id, name, project }); + } + } + return resources; +} + +/** Networks whose Compose project looks like an e2e fixture (any run). */ +export async function listFixtureNetworks(opts: { + readonly docker: DockerRunner; +}): Promise { + const networks = await listComposeResources({ + docker: opts.docker, + kind: "network", + }); + return ( + networks?.filter((network) => + E2E_FIXTURE_PROJECT_PATTERN.test(network.project) + ) ?? null + ); +} + +/** Networks that belong to the given owned fixture names. */ +export async function listOwnedNetworks(opts: { + readonly docker: DockerRunner; + readonly names: ReadonlySet; +}): Promise { + const networks = await listComposeResources({ + docker: opts.docker, + kind: "network", + }); + return ( + networks?.filter((network) => + isOwnedComposeProject({ project: network.project, names: opts.names }) + ) ?? null + ); +} + +/** + * Remove every container, network and volume owned by `names` (containers + * first so networks and volumes are free). Only exact resource IDs that pass + * the ownership check are removed; nothing is pruned. + */ +export async function sweepOwnedComposeResources(opts: { + readonly docker: DockerRunner; + readonly names: ReadonlySet; +}): Promise { + const removed: ComposeResource[] = []; + const failed: { resource: ComposeResource; error: string }[] = []; + const unlisted: ComposeResource["kind"][] = []; + if (opts.names.size === 0) { + return { removed, failed, unlisted }; + } + for (const kind of ["container", "network", "volume"] as const) { + const resources = await listComposeResources({ + docker: opts.docker, + kind, + }); + if (resources === null) { + unlisted.push(kind); + continue; + } + for (const resource of resources) { + if ( + !isOwnedComposeProject({ project: resource.project, names: opts.names }) + ) { + continue; + } + const args = + kind === "container" + ? ["rm", "-f", "-v", resource.id] + : [kind, "rm", resource.id]; + const result = await opts.docker(args); + if (result.exitCode === 0) { + removed.push(resource); + } else { + failed.push({ resource, error: result.stderr.trim() }); + } + } + } + return { removed, failed, unlisted }; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + // EPERM: the pid exists but belongs to someone else; treat as alive. + return (error as { code?: string }).code === "EPERM"; + } +} + +/** + * Sweep resources recorded by e2e runs that died without cleaning up (for + * example after SIGKILL). Journals of live processes are left alone, so a + * concurrent run is never disturbed. + */ +export async function recoverStaleOwnership(opts: { + readonly docker: DockerRunner; + readonly isAlive?: (pid: number) => boolean; +}): Promise<{ readonly recovered: SweepResult[] }> { + const dir = ownershipJournalDir(); + const isAlive = opts.isAlive ?? isProcessAlive; + const recovered: SweepResult[] = []; + await mkdir(dir, { recursive: true }); + for (const entry of await readdir(dir)) { + if (!entry.endsWith(".json")) { + continue; + } + const path = join(dir, entry); + const text = await readJournal(path); + const journal = text === null ? null : parseJournal(text); + if (journal === null || journal.pid === process.pid) { + continue; + } + if (isAlive(journal.pid)) { + continue; + } + const result = await sweepOwnedComposeResources({ + docker: opts.docker, + names: new Set(journal.names), + }); + recovered.push(result); + // Keep the journal until every kind was listed and every removal worked. + if (sweepComplete(result)) { + await rm(path, { force: true }); + } + } + return { recovered }; +} + +/** Journal text, or null when another run removed it after readdir. */ +async function readJournal(path: string): Promise { + try { + return await Bun.file(path).text(); + } catch (error: unknown) { + if ((error as { code?: string }).code === "ENOENT") { + return null; + } + throw error; + } +} + +function parseJournal( + text: string +): { readonly pid: number; readonly names: readonly string[] } | null { + try { + const parsed: unknown = JSON.parse(text); + if ( + typeof parsed === "object" && + parsed !== null && + "pid" in parsed && + "names" in parsed && + typeof parsed.pid === "number" && + Array.isArray(parsed.names) + ) { + return { + pid: parsed.pid, + names: parsed.names.filter( + (name): name is string => typeof name === "string" + ), + }; + } + } catch { + // unreadable journal: leave it for a human + } + return null; +} + +/** One-line description of resources for logs and failure messages. */ +export function describeResources( + resources: readonly ComposeResource[] +): string { + return resources + .map( + (resource) => `${resource.kind} ${resource.name} (${resource.project})` + ) + .join(", "); +} diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index 5f6009f9d..af08a9521 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -7,6 +7,7 @@ import { HACK_DIR_GITIGNORE_END_MARKER, HACK_DIR_GITIGNORE_ENTRIES, } from "../../src/templates.ts"; +import { recordOwnedFixture } from "./docker-ownership.ts"; import { runCommand } from "./harness.ts"; /** @@ -54,9 +55,15 @@ export type LifecycleFixtureOptions = { readonly downBeforeFailure?: boolean; }; -/** Random, collision-proof project slug (also used for dev_host). */ +/** + * Random, collision-proof project slug (also used for dev_host). The name is + * recorded as owned by this run, so the harness may remove its Docker + * resources (see docker-ownership.ts). + */ export function randomProjectName(): string { - return `e2e-${Math.random().toString(16).slice(2, 10)}`; + const name = `e2e-${Math.random().toString(16).slice(2, 10)}`; + recordOwnedFixture({ name }); + return name; } /** @@ -75,6 +82,8 @@ export async function createMonorepoFixture(opts: { readonly withHackGitignore?: boolean; }): Promise { const name = opts.name ?? randomProjectName(); + // Record ownership before anything can create Docker resources for it. + recordOwnedFixture({ name }); const devHost = `${name}.hack`; const root = join(opts.parentDir, name); const hackDir = join(root, ".hack"); diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 8b034ba96..98642e8c4 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -27,6 +27,18 @@ import { LAUNCHD_DISABLE_ENV, } from "../../src/daemon/launchd.ts"; +import { + type ComposeResource, + type DockerRunner, + describeResources, + listOwnedNetworks, + ownedFixtureNames, + runDocker, + type SweepResult, + sweepComplete, + sweepOwnedComposeResources, +} from "./docker-ownership.ts"; + /** * Core end-to-end harness for the hack CLI. * @@ -871,6 +883,8 @@ export type RunScenariosOptions = { readonly dockerEnabled: boolean; readonly only?: readonly string[]; readonly keepTempDirs?: boolean; + /** Docker CLI used for the network leak check (tests inject a fake). */ + readonly docker?: DockerRunner; }; /** Host ingress qualification requires explicit selection; Docker enablement is insufficient. */ @@ -903,6 +917,83 @@ async function cleanupScenarioRoots(opts: { } } +export type ScenarioDockerLeakCheck = { + /** Owned networks still present after the scenario's own cleanup. */ + readonly leaked: readonly ComposeResource[]; + /** The harness sweep that removed the scenario's owned resources. */ + readonly sweep: SweepResult; + /** Owned networks still present after the sweep (should be empty). */ + readonly remaining: readonly ComposeResource[]; +}; + +/** + * After a docker scenario's own cleanup: record which owned networks it left + * behind, then remove every container, network and volume it owns and + * verify the networks are gone. Returns null when docker cannot be queried. + */ +export async function checkScenarioDockerLeaks(opts: { + readonly docker: DockerRunner; + readonly names: ReadonlySet; +}): Promise { + if (opts.names.size === 0) { + return { + leaked: [], + sweep: { removed: [], failed: [], unlisted: [] }, + remaining: [], + }; + } + const leaked = await listOwnedNetworks(opts); + if (leaked === null) { + return null; + } + const sweep = await sweepOwnedComposeResources(opts); + const remaining = (await listOwnedNetworks(opts)) ?? leaked; + return { leaked, sweep, remaining }; +} + +/** Failure text for a leak check, or null when the scenario left nothing. */ +export function describeDockerLeak( + check: ScenarioDockerLeakCheck +): string | null { + if (check.leaked.length === 0 && sweepComplete(check.sweep)) { + return null; + } + const lines: string[] = []; + if (check.leaked.length > 0) { + lines.push( + `scenario left ${check.leaked.length} Docker network(s) after its cleanup: ${describeResources(check.leaked)}` + ); + } + if (check.sweep.removed.length > 0) { + lines.push( + `harness sweep removed: ${describeResources(check.sweep.removed)}` + ); + } + for (const failure of check.sweep.failed) { + lines.push( + `harness sweep could not remove ${describeResources([failure.resource])}: ${failure.error}` + ); + } + if (check.sweep.unlisted.length > 0) { + lines.push( + `harness sweep could not list: ${check.sweep.unlisted.join(", ")}` + ); + } + if (check.remaining.length > 0) { + lines.push( + `still present after sweep: ${describeResources(check.remaining)}` + ); + } + return lines.join("\n"); +} + +function newNames(opts: { + readonly before: ReadonlySet; + readonly after: ReadonlySet; +}): Set { + return new Set([...opts.after].filter((name) => !opts.before.has(name))); +} + /** * Run scenarios sequentially, printing a plain-text progress log and a final * summary table. Returns the outcomes; the caller decides the exit code. @@ -949,6 +1040,7 @@ export async function runScenarios( }; process.stdout.write(`-- ${scenario.name} (${scenario.tier}) --\n`); + const namesBefore = ownedFixtureNames(); let outcome: ScenarioOutcome; let preserveFailedFixture = false; try { @@ -999,6 +1091,14 @@ export async function runScenarios( log: ctx.log, }); } + if (scenario.tier === "docker") { + outcome = await applyDockerLeakCheck({ + outcome, + docker: opts.docker ?? runDocker, + names: newNames({ before: namesBefore, after: ownedFixtureNames() }), + log: ctx.log, + }); + } outcomes.push(outcome); printOutcome(outcome); } @@ -1007,6 +1107,33 @@ export async function runScenarios( return outcomes; } +async function applyDockerLeakCheck(opts: { + readonly outcome: ScenarioOutcome; + readonly docker: DockerRunner; + readonly names: ReadonlySet; + readonly log: (message: string) => void; +}): Promise { + const check = await checkScenarioDockerLeaks({ + docker: opts.docker, + names: opts.names, + }); + if (check === null) { + opts.log("network leak check skipped: docker network ls failed"); + return opts.outcome; + } + const leak = describeDockerLeak(check); + if (leak === null) { + return opts.outcome; + } + if (opts.outcome.status === "fail") { + return { + ...opts.outcome, + reason: `${opts.outcome.reason ?? ""}\n${leak}`, + }; + } + return { ...opts.outcome, status: "fail", reason: leak }; +} + function printOutcome(outcome: ScenarioOutcome): void { const seconds = (outcome.durationMs / 1000).toFixed(1); const suffix = outcome.reason ? ` — ${firstLine(outcome.reason)}` : ""; diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index f4eb07576..cf172c4a4 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -1,3 +1,13 @@ +import { + describeResources, + listFixtureNetworks, + listOwnedNetworks, + ownedFixtureNames, + recoverStaleOwnership, + runDocker, + settleRunOwnership, + sweepOwnedComposeResources, +} from "./docker-ownership.ts"; import { blockedLaunchctlCalls, describeLaunchAgentChange, @@ -81,9 +91,10 @@ import { parseShard, type ShardSelection, selectShard } from "./shard.ts"; * HACK_E2E_REQUIRE_DOCKER=1 ... # fail if a Docker scenario skips * HACK_E2E_REQUIRE_TMUX=1 ... # fail if tmux recovery cannot run * - * Exit codes: 0 all pass/skip, 1 any scenario failed, 2 isolation canary - * failed (nothing ran) or the launchd canary saw the real hackd plist change - * or a refused hackd launchctl call (HACK-1313). + * Exit codes: 0 all pass/skip, 1 any scenario failed or the run left an + * owned Docker network behind, 2 isolation canary failed (nothing ran) or + * the launchd canary saw the real hackd plist change or a refused hackd + * launchctl call (HACK-1313). */ export const ALL_SCENARIOS: readonly Scenario[] = [ @@ -299,23 +310,115 @@ async function runSuite(opts: { const dockerEnabled = (process.env.HACK_E2E_DOCKER ?? "") === "1"; const requireDocker = (process.env.HACK_E2E_REQUIRE_DOCKER ?? "") === "1"; const keepTempDirs = (process.env.HACK_E2E_KEEP ?? "") === "1"; + const networksBefore = dockerEnabled ? await prepareDockerRun() : null; const outcomes = await runScenarios({ scenarios: ALL_SCENARIOS, dockerEnabled, only: shardNames ?? args.only, keepTempDirs, }); + const networksLeft = dockerEnabled + ? await finishDockerRun({ networksBefore }) + : false; const dockerSkipped = outcomes.some( (outcome) => outcome.tier === "docker" && outcome.status === "skip" ); process.exitCode = outcomes.some((outcome) => outcome.status === "fail") || + networksLeft || (requireDocker && dockerSkipped) ? 1 : 0; } +const TERMINATION_SIGNALS = { + SIGHUP: 1, + SIGINT: 2, + SIGTERM: 15, +} as const; + +/** + * Before any docker scenario: reclaim resources journaled by e2e runs that + * died without cleaning up, count fixture networks, and make an interrupted + * run sweep what it owns before exiting. + */ +async function prepareDockerRun(): Promise { + const { recovered } = await recoverStaleOwnership({ docker: runDocker }); + for (const sweep of recovered) { + if (sweep.removed.length > 0) { + process.stdout.write( + `reclaimed from a dead e2e run: ${describeResources(sweep.removed)}\n` + ); + } + } + installInterruptCleanup(); + const networks = await listFixtureNetworks({ docker: runDocker }); + return networks?.length ?? null; +} + +let interrupted = false; + +function installInterruptCleanup(): void { + for (const [signal, number] of Object.entries(TERMINATION_SIGNALS)) { + process.on(signal, async () => { + if (interrupted) { + return; + } + interrupted = true; + process.stdout.write( + `\n${signal}: removing Docker resources owned by this run\n` + ); + const sweep = await sweepOwnedComposeResources({ + docker: runDocker, + names: ownedFixtureNames(), + }); + if (sweep.removed.length > 0) { + process.stdout.write(`removed: ${describeResources(sweep.removed)}\n`); + } + // The journal stays: a CLI child still running may recreate resources, + // and the next run reclaims them once this process is gone. + process.exit(128 + number); + }); + } +} + +/** + * After all scenarios: count fixture networks again and fail the run when + * any network owned by this run is still present. A full sweep of the run's + * owned resources always follows; the ownership journal is cleared only when + * that sweep listed every resource kind and every removal succeeded, so a + * later run can still reclaim anything left. + */ +async function finishDockerRun(opts: { + readonly networksBefore: number | null; +}): Promise { + const names = ownedFixtureNames(); + const owned = await listOwnedNetworks({ docker: runDocker, names }); + const after = await listFixtureNetworks({ docker: runDocker }); + if (owned === null || after === null || opts.networksBefore === null) { + process.stdout.write("\nnetwork check skipped: docker network ls failed\n"); + } else { + process.stdout.write( + `\nfixture networks: before=${opts.networksBefore} after=${after.length} left by this run=${owned.length}\n` + ); + } + if (owned !== null && owned.length > 0) { + process.stdout.write( + `LEAK: this run left Docker networks: ${describeResources(owned)}\n` + ); + } + const { sweep, journalCleared } = await settleRunOwnership({ + docker: runDocker, + }); + if (!journalCleared) { + process.stdout.write( + `ownership journal kept for a later run: sweep incomplete (could not list: ${sweep.unlisted.join(", ") || "none"}; failed removals: ${describeResources(sweep.failed.map((failure) => failure.resource)) || "none"})\n` + ); + } + return owned !== null && owned.length > 0; +} + if (import.meta.main) { await main(); } diff --git a/tests/e2e/scenarios/docker-shared.ts b/tests/e2e/scenarios/docker-shared.ts index b25fbe72d..373894d1d 100644 --- a/tests/e2e/scenarios/docker-shared.ts +++ b/tests/e2e/scenarios/docker-shared.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; +import { branchComposeProject } from "../docker-ownership.ts"; import type { MonorepoFixture } from "../fixture.ts"; import { runCommand, type ScenarioContext } from "../harness.ts"; @@ -40,7 +41,9 @@ export async function requireDockerPreconditions(opts: { * Always-run cleanup for docker scenarios: `hack down` for the primary (and * optional branch) instance, then a raw `docker compose down` sweep so a * broken CLI path can never leak containers. Fixture project names are - * random (e2e-) so this can never collide with real projects. + * random (e2e-) so this can never collide with real projects. The + * harness then fails the scenario if any owned network is still present and + * sweeps it (see docker-ownership.ts). */ export async function downBestEffort(opts: { readonly ctx: ScenarioContext; @@ -62,7 +65,9 @@ export async function downBestEffort(opts: { const composeProjects = [ opts.fixture.name, - ...(opts.branches ?? []).map((branch) => `${opts.fixture.name}-${branch}`), + ...(opts.branches ?? []).map((branch) => + branchComposeProject({ name: opts.fixture.name, branch }) + ), ]; for (const composeProject of composeProjects) { await runCommand({ From 40d0df6fc5932a910225416026ed101f0732a62c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:09:55 -0500 Subject: [PATCH 07/19] fix(env): harden primary local env inheritance on next (HACK-1758) Next already inherited primary local overlays in linked worktrees, but main's 736544ef regression suite found these gaps, 24 of its 36 cases failing before this change: - verify the Git worktree family before reading a primary checkout, so a redirected .git file cannot pull in another checkout's local overlays - add the HACK_WORKTREE_INHERIT_LOCAL=0 opt-out, checked before any Git work - only unreadable or malformed project config blocks inheritance, not a field error elsewhere in a well-formed file - reject non-regular inherited files, and fail closed on unreadable or unparsable ones with a path-only error - route inherited-only worktrees to the YAML env commands - have doctor report a materialized .env whose inputs are all gone as obsolete Two of main's test expectations keep next's behaviour: the layer guard's refusal message for a symlinked default file, and the tombstone a local unset leaves so it can hide an inherited value. The graph runner keeps following only worktree.inherit_local, since its adoption proofs record that setting. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 6 +- docs/core.md | 2 + docs/env.md | 23 +- src/commands/doctor.ts | 9 +- src/lib/project-env-config.ts | 49 +- src/lib/worktree-local-config.ts | 186 ++++-- tests/project-env-worktree-local.test.ts | 689 +++++++++++++++++++++++ 7 files changed, 904 insertions(+), 60 deletions(-) create mode 100644 tests/project-env-worktree-local.test.ts diff --git a/docs/cli.md b/docs/cli.md index a277f45b2..f0c031239 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -480,8 +480,10 @@ Linked worktrees read eligible primary-checkout local env overrides and managed extra-host aliases at command time. Later primary changes are visible without copying files. Tracked project config, generated overrides, runtime state and other ignored files stay checkout-local. Set `worktree.inherit_local` to `false` in the -worktree's `hack.config.json` to disable inheritance. Slim/Codex execution mode and -CI (`CI=1` or `CI=true`) disable this inheritance automatically. +worktree's `hack.config.json` to disable inheritance. `HACK_WORKTREE_INHERIT_LOCAL=0` +does the same for the Compose path; the experimental graph runner follows the config +setting only. Slim/Codex execution mode and CI (`CI=1` or `CI=true`) disable this +inheritance automatically. For Compose aliases, explicit local `internal.extra_hosts` takes precedence over inherited dynamic aliases, and local dynamic aliases take precedence over both. diff --git a/docs/core.md b/docs/core.md index 205fb4787..c247f19bd 100644 --- a/docs/core.md +++ b/docs/core.md @@ -50,6 +50,8 @@ start with the project instead of relying on an extra terminal tab. In a linked git worktree, `hack up` defaults to a branch instance named after the git branch. The secret key is inherited from the primary checkout through the shared git directory. A detached worktree needs an explicit `--branch `. +The worktree also reads the primary checkout's local env overlays live, below its +own local overrides; see [Environment and secrets](env.md#merge-rules). See [Worktrees and watchers](guides/development-runtime-safety.md) for shared files, watchers and dependency caches, and [Sessions](sessions.md) for persistent project workspaces. diff --git a/docs/env.md b/docs/env.md index 9cd783c4a..d42c464c6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -176,12 +176,27 @@ Hack resolves env in this order: 1. load `.hack/hack.env.default.yaml` 2. if `--env=` is selected, load `.hack/hack.env..yaml` -3. load `.hack/hack.env.local.yaml` -4. if `--env=` is selected, load `.hack/hack.env..local.yaml` -5. merge `values.global` -6. if a service scope is requested, merge `values.` on top +3. in a linked git worktree, load the primary checkout's `.hack/hack.env.local.yaml` +4. if `--env=` is selected, load the primary checkout's `.hack/hack.env..local.yaml` +5. load this checkout's `.hack/hack.env.local.yaml` +6. if `--env=` is selected, load this checkout's `.hack/hack.env..local.yaml` +7. merge `values.global` +8. if a service scope is requested, merge `values.` on top Worktree-local overrides win over shared repo overlays. Service values override global values. +Primary local overlays are read live, without copying or creating files. Checkout-local +overrides always win over primary local overlays; `hack env set --local` writes only to +the current checkout. Inheritance is disabled by `CI=1` or `CI=true`, +`HACK_EXECUTION_MODE=slim` or `codex`, `HACK_WORKTREE_INHERIT_LOCAL=0` (also +`false`, `off`, or `no`), or `"worktree": { "inherit_local": false }` in +`.hack/hack.config.json`. Symlinked primary project directories are ignored; +symlinked, non-regular, or unreadable primary local files are rejected, and an +unreadable `.hack/hack.config.json` in a linked worktree is an error rather than a +silent opt-in. Checkout paths containing newlines never inherit. A worktree +whose only env input is an inherited overlay still uses the YAML commands. If every +input of a materialized `.hack/.env` later disappears (including by opting out), +`hack doctor` reports the output as obsolete. + Host execution applies `host` values last. This includes `hack host exec`, `hack host shell`, and lifecycle hooks/processes, which all run on the host rather than in Compose. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 8455f69c9..32c72d53f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -43,6 +43,7 @@ import { LEGACY_GRAFANA_HOST, PROJECT_ENV_CONTRACT_FILENAME, PROJECT_ENV_KEY_FILENAME, + PROJECT_ENV_STATE_FILENAME, } from "../constants.ts"; import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config.ts"; import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts"; @@ -2780,10 +2781,12 @@ async function checkProjectEnvMaterialization({ }; } + // Recorded state means modern inputs once existed; inspect it for staleness. if ( - !(await projectEnvConfigExists({ - projectDir: ctx.projectDir, - })) + !( + (await projectEnvConfigExists({ projectDir: ctx.projectDir })) || + (await pathExists(resolve(ctx.projectDir, PROJECT_ENV_STATE_FILENAME))) + ) ) { return { name: "env materialization", diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 9a3faa712..88a5cd0b8 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -92,7 +92,9 @@ import { withProjectsRegistryLock, } from "./projects-registry-lock.ts"; import { + hasPrimaryLocalInputs, isNativeManagedLocalTracked, + readPrimaryLocalFile, resolvePrimaryLocalProjectDir, resolveVerifiedPrimaryWorktreeRoot, resolveVerifiedProjectEnvKeyGitLocation, @@ -923,7 +925,7 @@ export async function projectEnvConfigExists(opts: { } const entries = await readdir(opts.projectDir, { withFileTypes: true }); - return entries.some((entry) => { + const hasLocalConfig = entries.some((entry) => { if (!entry.isFile()) { return false; } @@ -935,6 +937,15 @@ export async function projectEnvConfigExists(opts: { } return entry.name !== PROJECT_ENV_CONFIG_DEFAULT_FILENAME; }); + if (hasLocalConfig) { + return true; + } + + // Inherited-only linked worktrees still route to the modern YAML commands. + return await hasPrimaryLocalInputs({ + projectRoot: dirname(opts.projectDir), + projectDir: opts.projectDir, + }); } export async function listProjectEnvOverlayNames(opts: { @@ -1008,8 +1019,12 @@ async function readProjectEnvConfigFile(opts: { readonly path: string; readonly environment: string; readonly strictRead?: boolean; + /** Inherited layers replace the normal read with their fail-closed one. */ + readonly readText?: (path: string) => Promise; }): Promise { - const text = await readProjectEnvLayerText(opts); + const text = opts.readText + ? await opts.readText(opts.path) + : await readProjectEnvLayerText(opts); return parseProjectEnvConfigText({ path: opts.path, environment: opts.environment, @@ -1275,15 +1290,15 @@ async function readProjectEnvLayers(opts: { projectDir: primaryDir, envName: null, }); + await validatePrimaryLocalFile(primaryDefaultPath); await assertProjectEnvLayerPath({ projectRoot: dirname(primaryDir), path: primaryDefaultPath, }); - await validatePrimaryLocalFile(primaryDefaultPath); inheritedReads.push( await readProjectEnvConfigFile({ projectRoot: dirname(primaryDir), - strictRead: opts.strictRead, + readText: readPrimaryLocalFile, path: primaryDefaultPath, environment: "default", }) @@ -1293,20 +1308,25 @@ async function readProjectEnvLayers(opts: { projectDir: primaryDir, envName: selection.effectiveEnv, }); + await validatePrimaryLocalFile(primaryOverlayPath); await assertProjectEnvLayerPath({ projectRoot: dirname(primaryDir), path: primaryOverlayPath, }); - await validatePrimaryLocalFile(primaryOverlayPath); inheritedReads.push( await readProjectEnvConfigFile({ projectRoot: dirname(primaryDir), - strictRead: opts.strictRead, + readText: readPrimaryLocalFile, path: primaryOverlayPath, environment: selection.effectiveEnv, }) ); } + for (const read of inheritedReads) { + if (read.parseError) { + throw new Error(`Failed to parse primary local env file: ${read.path}`); + } + } } // Primary local settings are live inputs, subordinate to checkout-local choices. const reads = [ @@ -3707,12 +3727,23 @@ export async function inspectProjectEnvMaterialization(opts: { serviceNames: opts.serviceNames, }); if (!resolved) { + // Every recorded input is gone (e.g. inheritance disabled or removed). + if (Object.keys(stateRead.state.inputs).length === 0) { + return { + envPath, + statePath, + status: "ok", + message: "No modern env config files found", + issues: [], + }; + } + const issue = `no env inputs remain; materialized ${envPath} is obsolete`; return { envPath, statePath, - status: "ok", - message: "No modern env config files found", - issues: [], + status: "warn", + message: `${issue} (remove ${envPath} and ${statePath}, or restore inputs and run: hack env materialize)`, + issues: [issue], }; } diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index c8643f0d9..ba1902ab2 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -1,11 +1,11 @@ -import { lstat, realpath } from "node:fs/promises"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +} from "../constants.ts"; import { HackCliError } from "./cli-result.ts"; import { isSlimExecutionMode } from "./execution-mode.ts"; -import { - isLinkedGitWorktree, - resolveGitPrimaryWorktreeRoot, -} from "./git-worktree.ts"; import { isRecord } from "./guards.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { readProjectConfig, sanitizeBranchSlug } from "./project.ts"; @@ -584,74 +584,176 @@ function worktreeVerificationError(): HackCliError { }); } -/** Resolve only the matching primary configuration directory; never copy state. */ +/** `HACK_WORKTREE_INHERIT_LOCAL=0` opts this checkout out of local env inheritance. */ +function isLocalInheritanceDisabledByEnv(): boolean { + return ["0", "false", "off", "no"].includes( + (process.env.HACK_WORKTREE_INHERIT_LOCAL ?? "").trim().toLowerCase() + ); +} + +/** + * Resolve only an existing, direct primary project directory; never copy state. + * Every opt-out (env, CI, slim mode, project config) is checked before any Git + * subprocess runs. A linkage that fails verification does not inherit. + */ export async function resolvePrimaryLocalProjectDir(opts: { readonly projectRoot: string; readonly projectDir: string; }): Promise { - if (!shouldInheritPrimaryLocalInputs({ inheritLocal: true })) { + if ( + !shouldInheritPrimaryLocalInputs({ inheritLocal: true }) || + isLocalInheritanceDisabledByEnv() + ) { return null; } const name = basename(opts.projectDir); if ( - ![".hack", ".dev"].includes(name) || + (name !== ".hack" && name !== ".dev") || relative(opts.projectRoot, opts.projectDir) !== name ) { return null; } + // Primary checkouts never inherit; only a `.git` file marks a linked worktree. + const marker = await lstat(resolve(opts.projectRoot, ".git")).catch( + () => null + ); + if (!marker?.isFile()) { + return null; + } const cfg = await readProjectConfig({ ...opts, - projectDirName: name === ".dev" ? ".dev" : ".hack", + projectDirName: name, composeFile: resolve(opts.projectDir, "docker-compose.yml"), envFile: resolve(opts.projectDir, ".env"), configFile: resolve(opts.projectDir, "hack.config.json"), }); - if (cfg.parseError) { - throw new Error( - "Cannot inherit worktree configuration with invalid project config" - ); + if (cfg.worktree?.inheritLocal === false) { + return null; + } + const primaryRoot = await resolveVerifiedPrimaryWorktreeRoot({ + projectRoot: opts.projectRoot, + }).catch((error: unknown) => { + // Unverifiable linkage (bare common dirs, foreign git dirs) reads no primary. + if (error instanceof HackCliError) { + return null; + } + throw error; + }); + if (!primaryRoot) { + return null; } + await assertProjectConfigReadable({ projectDir: opts.projectDir }); + const candidate = resolve(primaryRoot, name); + const info = await lstat(candidate).catch(() => null); if ( - cfg.worktree?.inheritLocal === false || - !(await isLinkedGitWorktree({ repoRoot: opts.projectRoot })) + !info?.isDirectory() || + (await realpath(candidate).catch(() => { + throw primaryLocalReadError(candidate); + })) !== candidate ) { return null; } - const primary = await resolveGitPrimaryWorktreeRoot({ - repoRoot: opts.projectRoot, - }); - if (!primary || resolve(primary) === resolve(opts.projectRoot)) { - return null; + return candidate; +} + +/** + * The config loader treats unreadable files as absent, which would hide a + * `worktree.inherit_local: false` opt-out; fail closed instead (path only). + * Field errors elsewhere in a well-formed file do not block env resolution. + */ +async function assertProjectConfigReadable(opts: { + readonly projectDir: string; +}): Promise { + for (const name of [ + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, + ]) { + const path = resolve(opts.projectDir, name); + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (error: unknown) { + if (!isEnoent(error)) { + throw new Error(`Cannot read project config: ${path}`); + } + continue; + } + let parsed: unknown; + try { + parsed = + name === PROJECT_CONFIG_FILENAME + ? JSON.parse(text) + : Bun.TOML.parse(text); + } catch { + throw new Error("Cannot inherit local env with invalid project config"); + } + if (!isRecord(parsed)) { + throw new Error("Cannot inherit local env with invalid project config"); + } + return; } - const candidate = resolve(primary, name); +} + +/** Whether the primary checkout holds any local overlay this checkout could inherit. */ +export async function hasPrimaryLocalInputs(opts: { + readonly projectRoot: string; + readonly projectDir: string; +}): Promise { + const primaryDir = await resolvePrimaryLocalProjectDir(opts); + if (primaryDir === null) { + return false; + } + const entries = await readdir(primaryDir, { withFileTypes: true }); + // Match by name only: a symlinked or non-regular overlay must still route to + // the YAML commands so resolution can reject it instead of falling back to .env. + return entries.some( + (entry) => + entry.name.startsWith("hack.env.") && entry.name.endsWith(".local.yaml") + ); +} + +/** Read an inherited file; only a missing file is skipped, other errors fail closed. */ +export async function readPrimaryLocalFile( + path: string +): Promise { try { - // A symlinked primary configuration must not redirect inheritance elsewhere. - if ( - (await realpath(candidate)) !== resolve(await realpath(primary), name) - ) { + return await readFile(path, "utf8"); + } catch (error: unknown) { + if (isEnoent(error)) { return null; } - return candidate; - } catch { - return null; + throw primaryLocalReadError(path); } } -/** Inherited inputs may not redirect through a file or nested-directory symlink. */ +function primaryLocalReadError(path: string): Error { + return new Error(`Cannot read primary local env file: ${path}`); +} + +function isEnoent(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} + +/** + * Inherited files must be regular files with no symlink redirection. Any + * filesystem error other than a missing file fails closed with the path only + * (macOS realpath raises EACCES for unreadable files). + */ export async function validatePrimaryLocalFile(path: string): Promise { - try { - if ((await realpath(path)) !== resolve(path)) { - throw new Error("Refusing redirected primary local configuration"); - } - } catch (error: unknown) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - return; + const failClosed = (error: unknown): null => { + if (isEnoent(error)) { + return null; } - throw error; + throw primaryLocalReadError(path); + }; + const info = await lstat(path).catch(failClosed); + if (!info) { + return; + } + const real = info.isFile() ? await realpath(path).catch(failClosed) : ""; + if (real !== null && real !== resolve(path)) { + throw new Error( + `Refusing redirected or non-regular primary local env file: ${path}` + ); } } diff --git a/tests/project-env-worktree-local.test.ts b/tests/project-env-worktree-local.test.ts new file mode 100644 index 000000000..fce844e90 --- /dev/null +++ b/tests/project-env-worktree-local.test.ts @@ -0,0 +1,689 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + inspectProjectEnvMaterialization, + materializeProjectEnv, + projectEnvConfigExists, + resolveProjectEnvConfig, + setProjectEnvValue, +} from "../src/lib/project-env-config.ts"; +import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; + +const tempDirs = new Set(); +const envKeys = [ + "CI", + "HACK_EXECUTION_MODE", + "HACK_WORKTREE_INHERIT_LOCAL", + "HACK_ENV_SECRET_KEY", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "PATH", +]; +let savedEnv: Array = []; + +beforeEach(() => { + savedEnv = envKeys.map((key) => process.env[key]); + for (const key of envKeys.slice(0, 4)) { + delete process.env[key]; + } + // Isolate the code under test from host Git configuration as well. + Object.assign(process.env, gitIsolationEnv); +}); + +afterEach(async () => { + for (const [index, key] of envKeys.entries()) { + const value = savedEnv[index]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +const gitIsolationEnv = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +async function git(cwd: string, args: string[]): Promise { + const isolation = [ + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "-c", + "init.templateDir=", + ]; + const result = Bun.spawnSync(["git", "-C", cwd, ...isolation, ...args], { + env: { ...process.env, ...gitIsolationEnv }, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString()); + } +} + +async function writeLayer( + root: string, + name: string, + values: Record +): Promise { + await writeFile( + resolve(root, ".hack", `hack.env.${name}.yaml`), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: values }, + }) + ); +} + +async function fixture(opts: { readonly nested?: string } = {}) { + const sandbox = await realpath( + await mkdtemp(resolve(tmpdir(), "hack-env-local-test-")) + ); + tempDirs.add(sandbox); + const primary = resolve(sandbox, opts.nested ?? "", "primary"); + const linked = resolve(sandbox, opts.nested ?? "", "linked"); + await mkdir(resolve(primary, ".hack"), { recursive: true }); + await writeFile(resolve(primary, ".hack", "hack.config.json"), "{}"); + await writeFile( + resolve(primary, ".hack", ".gitignore"), + "hack.env.local.yaml\nhack.env.*.local.yaml\n" + ); + await writeLayer(primary, "default", { + ORDER: "fake-tracked-base", + BASE: "fake-base", + }); + await writeLayer(primary, "qa", { ORDER: "fake-tracked-overlay" }); + await git(primary, ["init", "-b", "main"]); + await git(primary, ["add", "."]); + await git(primary, [ + "-c", + "user.name=Env Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "fixture", + ]); + await git(primary, ["worktree", "add", "-b", "test-local", linked]); + await writeLayer(primary, "local", { + ORDER: "fake-primary-base", + PRIMARY_BASE: "fake-primary-base", + }); + await writeLayer(primary, "qa.local", { + ORDER: "fake-primary-overlay", + PRIMARY_OVERLAY: "fake-primary-overlay", + }); + const options = { + projectRoot: linked, + projectDir: resolve(linked, ".hack"), + envName: "qa", + serviceNames: ["api"], + }; + return { sandbox, primary, linked, options }; +} + +/** Hash project files and paths, excluding Git bookkeeping, to detect any read-time writes. */ +async function snapshot(root: string): Promise> { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + const files: Record = {}; + for (const entry of entries) { + const path = resolve(entry.parentPath, entry.name); + if (path.startsWith(resolve(root, ".git"))) { + continue; + } + files[path] = entry.isFile() + ? createHash("sha256") + .update(await readFile(path)) + .digest("hex") + : "directory"; + } + return files; +} + +test("linked worktrees inherit live primary base and overlay without writing files", async () => { + const { primary, linked, options } = await fixture(); + const before = await Promise.all([snapshot(primary), snapshot(linked)]); + const resolved = await resolveProjectEnvConfig(options); + expect(resolved?.globalEnv).toEqual({ + BASE: "fake-base", + ORDER: "fake-primary-overlay", + PRIMARY_BASE: "fake-primary-base", + PRIMARY_OVERLAY: "fake-primary-overlay", + }); + expect(resolved?.serviceEnv.api).toEqual(resolved?.globalEnv); + expect(resolved?.files).toEqual([ + resolve(linked, ".hack/hack.env.default.yaml"), + resolve(linked, ".hack/hack.env.qa.yaml"), + resolve(primary, ".hack/hack.env.local.yaml"), + resolve(primary, ".hack/hack.env.qa.local.yaml"), + ]); + expect(await Promise.all([snapshot(primary), snapshot(linked)])).toEqual( + before + ); + await writeLayer(primary, "qa.local", { ORDER: "fake-live-update" }); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-live-update" + ); +}); + +test("each local layer overrides preceding layers and local writes stay in the checkout", async () => { + const { primary, linked, options } = await fixture(); + const primaryBefore = await snapshot(primary); + await writeLayer(linked, "local", { ORDER: "fake-checkout-base" }); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-checkout-base" + ); + await setProjectEnvValue({ + ...options, + local: true, + scope: "global", + key: "ORDER", + value: "fake-checkout-overlay", + secret: false, + }); + const resolved = await resolveProjectEnvConfig(options); + expect(resolved?.globalEnv.ORDER).toBe("fake-checkout-overlay"); + expect(resolved?.files.slice(-2)).toEqual([ + resolve(linked, ".hack/hack.env.local.yaml"), + resolve(linked, ".hack/hack.env.qa.local.yaml"), + ]); + expect(await snapshot(primary)).toEqual(primaryBefore); +}); + +test.each([ + ["CI", "1"], + ["CI", "true"], + ["HACK_EXECUTION_MODE", "codex"], + ["HACK_EXECUTION_MODE", "slim"], + ["HACK_WORKTREE_INHERIT_LOCAL", "0"], + ["HACK_WORKTREE_INHERIT_LOCAL", "false"], + ["HACK_WORKTREE_INHERIT_LOCAL", "off"], + ["HACK_WORKTREE_INHERIT_LOCAL", "no"], +])("%s=%s disables primary inheritance", async (key, value) => { + const { linked, options } = await fixture(); + process.env[key] = value; + await writeLayer(linked, "local", { CHECKOUT_ONLY: "fake-checkout" }); + const resolved = await resolveProjectEnvConfig(options); + expect(resolved?.globalEnv).toEqual({ + BASE: "fake-base", + ORDER: "fake-tracked-overlay", + CHECKOUT_ONLY: "fake-checkout", + }); + expect(resolved?.files).toHaveLength(3); +}); + +test.each([ + "inherit_local", + "inheritLocal", +])("worktree.%s=false disables primary inheritance", async (key) => { + const { linked, options } = await fixture(); + await writeFile( + resolve(linked, ".hack/hack.config.json"), + JSON.stringify({ worktree: { [key]: false } }) + ); + expect((await resolveProjectEnvConfig(options))?.globalEnv).toEqual({ + BASE: "fake-base", + ORDER: "fake-tracked-overlay", + }); +}); + +test.each([ + "hack.config.json", + "hack.toml", +])("unrelated field errors in %s do not block env resolution", async (name) => { + const { primary, linked, options } = await fixture(); + await rm(resolve(linked, ".hack/hack.config.json")); + await writeFile( + resolve(linked, ".hack", name), + name === "hack.config.json" + ? JSON.stringify({ open: { prefer: "bogus" } }) + : '[open]\nprefer = "bogus"\n' + ); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-primary-overlay" + ); + await rm(resolve(primary, ".hack/hack.env.local.yaml")); + await rm(resolve(primary, ".hack/hack.env.qa.local.yaml")); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-tracked-overlay" + ); +}); + +test.each([ + "{", + "[]", +])("invalid project config %s fails closed", async (text) => { + const { linked, options } = await fixture(); + await writeFile(resolve(linked, ".hack/hack.config.json"), text); + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + "Cannot inherit local env with invalid project config" + ); +}); + +test("primary checkout keeps its own local behavior", async () => { + const { primary, options } = await fixture(); + const resolved = await resolveProjectEnvConfig({ + ...options, + projectRoot: primary, + projectDir: resolve(primary, ".hack"), + }); + expect(resolved?.globalEnv.ORDER).toBe("fake-primary-overlay"); + expect(resolved?.files).toHaveLength(4); +}); + +test.each([ + "local", + "qa.local", +])("rejects symlinked primary %s files", async (name) => { + const { sandbox, primary, options } = await fixture(); + const path = resolve(primary, ".hack", `hack.env.${name}.yaml`); + const target = resolve(sandbox, "redirected.yaml"); + await rename(path, target); + await symlink(target, path); + // On next, the effective-path layer guard may refuse the default file first. + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + name === "local" + ? "Refused env layer" + : `Refusing redirected or non-regular primary local env file: ${path}` + ); +}); + +test("ignores a symlinked primary project directory", async () => { + const { primary, options, sandbox } = await fixture(); + const target = resolve(sandbox, "redirected-project"); + await rename(resolve(primary, ".hack"), target); + await symlink(target, resolve(primary, ".hack")); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-tracked-overlay" + ); +}); + +test("rejects non-regular primary local files before reading", async () => { + const { primary, options } = await fixture(); + const path = resolve(primary, ".hack/hack.env.local.yaml"); + await rm(path); + await mkdir(path); + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + `Refusing redirected or non-regular primary local env file: ${path}` + ); +}); + +test("does not inherit through a Git directory outside common-dir/worktrees", async () => { + const { sandbox, primary, linked, options } = await fixture(); + const redirected = resolve(sandbox, "gitdir"); + await rename(resolve(primary, ".git/worktrees/linked"), redirected); + await writeFile(resolve(redirected, "commondir"), resolve(primary, ".git")); + await writeFile(resolve(linked, ".git"), `gitdir: ${redirected}\n`); + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-tracked-overlay" + ); +}); + +test("legacy tracked local overlays inherit only the compatible local default", async () => { + const { primary, linked, options } = await fixture(); + await git(primary, ["add", "-f", ".hack/hack.env.local.yaml"]); + await git(primary, [ + "-c", + "user.name=Env Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "legacy fixture", + ]); + await git(linked, ["merge", "--ff-only", "main"]); + await writeLayer(primary, "default.local", { + ORDER: "fake-compatible-local", + }); + const resolved = await resolveProjectEnvConfig({ ...options, envName: null }); + expect(resolved?.globalEnv.ORDER).toBe("fake-compatible-local"); + expect(resolved?.globalEnv.PRIMARY_BASE).toBeUndefined(); + expect(resolved?.files).toContain( + resolve(primary, ".hack/hack.env.default.local.yaml") + ); + expect(resolved?.files).not.toContain( + resolve(primary, ".hack/hack.env.local.yaml") + ); +}); + +test("inherited-only config is sufficient and absent primary files create nothing", async () => { + const { primary, linked, options } = await fixture(); + for (const root of [primary, linked]) { + await rm(resolve(root, ".hack/hack.env.default.yaml")); + await rm(resolve(root, ".hack/hack.env.qa.yaml")); + } + expect((await resolveProjectEnvConfig(options))?.globalEnv.ORDER).toBe( + "fake-primary-overlay" + ); + await rm(resolve(primary, ".hack/hack.env.local.yaml")); + await rm(resolve(primary, ".hack/hack.env.qa.local.yaml")); + const before = await Promise.all([snapshot(primary), snapshot(linked)]); + expect(await resolveProjectEnvConfig(options)).toBeNull(); + expect(await Promise.all([snapshot(primary), snapshot(linked)])).toEqual( + before + ); +}); + +test("materialization consumes inherited inputs and notices primary changes", async () => { + const { primary, options } = await fixture(); + await materializeProjectEnv(options); + expect(await readFile(resolve(options.projectDir, ".env"), "utf8")).toContain( + "ORDER=fake-primary-overlay" + ); + expect((await inspectProjectEnvMaterialization(options)).status).toBe("ok"); + await writeLayer(primary, "local", { PRIMARY_BASE: "fake-updated-base" }); + const inspected = await inspectProjectEnvMaterialization(options); + expect(inspected.status).toBe("warn"); + expect(inspected.message).toContain("changed since materialization"); +}); + +test("inherited secure values use existing primary key fallback", async () => { + const { primary, options } = await fixture(); + await setProjectEnvValue({ + projectRoot: primary, + projectDir: resolve(primary, ".hack"), + envName: null, + local: true, + scope: "global", + key: "FAKE_SECURE", + value: "fake-test-only-value", + secret: true, + }); + const before = await snapshot(primary); + expect((await resolveProjectEnvConfig(options))?.globalEnv.FAKE_SECURE).toBe( + "fake-test-only-value" + ); + expect(await snapshot(primary)).toEqual(before); +}); + +test("unreadable primary local files fail closed without exposing contents", async () => { + if (process.getuid?.() === 0) { + return; + } + const { primary, options } = await fixture(); + const path = resolve(primary, ".hack/hack.env.qa.local.yaml"); + await chmod(path, 0o000); + try { + const error = await resolveProjectEnvConfig(options).then( + () => null, + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `Cannot read primary local env file: ${path}` + ); + } finally { + await chmod(path, 0o600); + } +}); + +async function runHack(cwd: string, args: string[], sandbox: string) { + const proc = Bun.spawn( + ["bun", resolve(import.meta.dir, "../index.ts"), ...args], + { + cwd, + env: { + ...process.env, + HACK_GLOBAL_CONFIG_PATH: resolve(sandbox, "global.config.json"), + HACK_NO_INTERACTIVE: "1", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, output: `${stdout}\n${stderr}` }; +} + +test("env set/unset --local in an inherited-only worktree edit only checkout YAML", async () => { + const { sandbox, primary, linked } = await fixture(); + for (const root of [primary, linked]) { + await rm(resolve(root, ".hack/hack.env.default.yaml")); + await rm(resolve(root, ".hack/hack.env.qa.yaml")); + } + await writeFile( + resolve(linked, ".hack/docker-compose.yml"), + "services:\n api:\n image: busybox\n" + ); + const primaryBefore = await snapshot(primary); + const localPath = resolve(linked, ".hack/hack.env.local.yaml"); + + const set = await runHack( + linked, + ["env", "set", "--local", "FAKE_CLI", "fake-cli-value"], + sandbox + ); + expect(set.output).not.toContain("fake-cli-value"); + expect(set.exitCode).toBe(0); + expect(await readFile(localPath, "utf8")).toContain("FAKE_CLI"); + expect(await readdir(resolve(linked, ".hack"))).not.toContain(".env"); + expect(await snapshot(primary)).toEqual(primaryBefore); + + const unset = await runHack( + linked, + ["env", "unset", "--local", "FAKE_CLI"], + sandbox + ); + expect(unset.exitCode).toBe(0); + // On next, a local unset in a linked worktree leaves a tombstone that also + // hides an inherited value from the primary. + expect(await readFile(localPath, "utf8")).toContain("FAKE_CLI: null"); + expect(await readdir(resolve(linked, ".hack"))).not.toContain(".env"); + expect(await snapshot(primary)).toEqual(primaryBefore); +}, 30_000); + +test.each([ + "removed", + "opted-out", +])("materialization from inherited-only inputs is stale once %s", async (mode) => { + const { primary, linked, options } = await fixture(); + for (const root of [primary, linked]) { + await rm(resolve(root, ".hack/hack.env.default.yaml")); + await rm(resolve(root, ".hack/hack.env.qa.yaml")); + } + await materializeProjectEnv(options); + expect((await inspectProjectEnvMaterialization(options)).status).toBe("ok"); + if (mode === "removed") { + await rm(resolve(primary, ".hack/hack.env.local.yaml")); + await rm(resolve(primary, ".hack/hack.env.qa.local.yaml")); + } else { + process.env.HACK_WORKTREE_INHERIT_LOCAL = "0"; + } + expect(await resolveProjectEnvConfig(options)).toBeNull(); + const inspected = await inspectProjectEnvMaterialization(options); + expect(inspected.status).toBe("warn"); + expect(inspected.message).toContain("no env inputs remain; materialized"); +}); + +/** Put a logging `git` shim first on PATH to count subprocesses. */ +async function countGitCalls(sandbox: string): Promise<() => Promise> { + const realGit = Bun.which("git"); + if (!realGit) { + throw new Error("git is required for this test"); + } + const bin = resolve(sandbox, "bin"); + const log = resolve(sandbox, "git-calls.log"); + await mkdir(bin, { recursive: true }); + await writeFile(log, ""); + await writeFile( + resolve(bin, "git"), + `#!/bin/sh\necho call >> "${log}"\nexec "${realGit}" "$@"\n` + ); + await chmod(resolve(bin, "git"), 0o755); + process.env.PATH = `${bin}:${process.env.PATH ?? ""}`; + return async () => + (await readFile(log, "utf8")).split("\n").filter(Boolean).length; +} + +test("opt-outs skip all Git work and verification uses three Git calls", async () => { + const { sandbox, primary, linked, options } = await fixture(); + const calls = await countGitCalls(sandbox); + process.env.HACK_WORKTREE_INHERIT_LOCAL = "0"; + expect(await resolvePrimaryLocalProjectDir(options)).toBeNull(); + Reflect.deleteProperty(process.env, "HACK_WORKTREE_INHERIT_LOCAL"); + const configPath = resolve(linked, ".hack/hack.config.json"); + await writeFile( + configPath, + JSON.stringify({ worktree: { inherit_local: false } }) + ); + expect(await resolvePrimaryLocalProjectDir(options)).toBeNull(); + expect(await calls()).toBe(0); + await writeFile(configPath, "{}"); + expect(await resolvePrimaryLocalProjectDir(options)).toBe( + resolve(primary, ".hack") + ); + expect(await calls()).toBe(3); +}); + +const runningAsRoot = process.getuid?.() === 0; + +test.skipIf(runningAsRoot)( + "inaccessible primary project directories fail closed with the path only", + async () => { + const { primary, options } = await fixture(); + const dir = resolve(primary, ".hack"); + await chmod(dir, 0o000); + try { + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + `Cannot read primary local env file: ${dir}` + ); + } finally { + await chmod(dir, 0o755); + } + } +); + +test.skipIf(runningAsRoot).each(["hack.config.json", "hack.toml"])( + "unreadable %s fails closed after verifying the worktree family", + async (name) => { + const { sandbox, linked, options } = await fixture(); + const path = resolve(linked, ".hack", name); + if (name === "hack.toml") { + await rm(resolve(linked, ".hack/hack.config.json")); + await writeFile(path, "[worktree]\ninherit_local = false\n"); + } else { + await writeFile( + path, + JSON.stringify({ worktree: { inherit_local: false } }) + ); + } + await chmod(path, 0o000); + const calls = await countGitCalls(sandbox); + try { + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + `Cannot read project config: ${path}` + ); + expect(await calls()).toBe(3); + } finally { + await chmod(path, 0o644); + } + } +); + +test.skipIf(runningAsRoot)( + "a separate-git-dir checkout does not inherit or validate unrelated config", + async () => { + const { primary, sandbox, options } = await fixture(); + const gitDir = resolve(sandbox, "separate.git"); + await rename(resolve(primary, ".git"), gitDir); + await writeFile(resolve(primary, ".git"), `gitdir: ${gitDir}\n`); + const configPath = resolve(primary, ".hack/hack.config.json"); + await chmod(configPath, 0o000); + try { + expect( + await resolvePrimaryLocalProjectDir({ + ...options, + projectRoot: primary, + projectDir: resolve(primary, ".hack"), + }) + ).toBeNull(); + } finally { + await chmod(configPath, 0o644); + } + } +); + +test.each([ + "symlink", + "directory", +])("a %s-only primary overlay routes env set --local to YAML commands", async (kind) => { + const { sandbox, primary, linked } = await fixture(); + for (const root of [primary, linked]) { + await rm(resolve(root, ".hack/hack.env.default.yaml")); + await rm(resolve(root, ".hack/hack.env.qa.yaml")); + } + await rm(resolve(primary, ".hack/hack.env.qa.local.yaml")); + const overlay = resolve(primary, ".hack/hack.env.local.yaml"); + if (kind === "symlink") { + const target = resolve(sandbox, "redirected.yaml"); + await rename(overlay, target); + await symlink(target, overlay); + } else { + await rm(overlay); + await mkdir(overlay); + } + await writeFile( + resolve(linked, ".hack/docker-compose.yml"), + "services:\n api:\n image: busybox\n" + ); + expect( + await projectEnvConfigExists({ projectDir: resolve(linked, ".hack") }) + ).toBe(true); + const primaryBefore = await snapshot(primary); + for (const args of [ + ["env", "set", "--local", "FAKE_CLI", "fake-cli-value"], + ["env", "unset", "--local", "FAKE_CLI"], + ]) { + const result = await runHack(linked, args, sandbox); + expect(result.output).not.toContain("fake-cli-value"); + expect(await readdir(resolve(linked, ".hack"))).not.toContain(".env"); + if (result.exitCode !== 0) { + expect(result.output).toContain( + `Refusing redirected or non-regular primary local env file: ${overlay}` + ); + } + } + expect(await snapshot(primary)).toEqual(primaryBefore); +}, 30_000); + +test("newline-containing checkout paths cleanly disable inheritance", async () => { + const { primary, linked, options } = await fixture({ nested: "line\nbreak" }); + const before = await Promise.all([snapshot(primary), snapshot(linked)]); + const resolved = await resolveProjectEnvConfig(options); + expect(resolved?.globalEnv).toEqual({ + BASE: "fake-base", + ORDER: "fake-tracked-overlay", + }); + expect(await Promise.all([snapshot(primary), snapshot(linked)])).toEqual( + before + ); +}); From f97ca46256dad136d5d4694bcc5d44f9d22bb8b4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:09:56 -0500 Subject: [PATCH 08/19] ci: forward Dependabot bun config and Blacksmith checkout to next (HACK-1758) Forward main's 395351bc and 541c611b. Jobs on Blacksmith Ubuntu runners use useblacksmith/checkout@v1 with the same inputs; jobs on hosted and macOS runners keep actions/checkout, as on main. No permissions or secrets change. Main's 1c1cc983 runner sizing is left out: its measurements come from main's single docker-e2e job, and next shards that job. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 8 ++++++++ .github/workflows/ci.yml | 8 ++++---- .github/workflows/release-node-runtime-image.yml | 2 +- .github/workflows/release-prepare.yml | 2 +- .github/workflows/release.yml | 6 +++--- 5 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b6a080190 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "bun" + directories: + - "/" + - "/examples/next-app" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7a06d38c..cbab885c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,7 +195,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: fetch-depth: 0 - name: Install Gitleaks @@ -222,7 +222,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Setup Bun uses: oven-sh/setup-bun@v1 with: @@ -269,7 +269,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Setup Bun uses: oven-sh/setup-bun@v1 with: @@ -448,7 +448,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Setup Bun uses: oven-sh/setup-bun@v1 with: diff --git a/.github/workflows/release-node-runtime-image.yml b/.github/workflows/release-node-runtime-image.yml index 8ad50fa04..7d4b46918 100644 --- a/.github/workflows/release-node-runtime-image.yml +++ b/.github/workflows/release-node-runtime-image.yml @@ -22,7 +22,7 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Setup Bun uses: oven-sh/setup-bun@v1 diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 3b4cc04f8..af5e941c8 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -20,7 +20,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b553ec86..f54c764da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: fetch-depth: 0 - name: Setup Bun @@ -198,9 +198,9 @@ jobs: exit 1 fi - name: Checkout hack - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Checkout tap repo - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: repository: hack-dance/homebrew-tap token: ${{ secrets.RELEASE_PAT }} From 25dfed3eb23ca585a78356654fb7be2f2c06c473 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:09:56 -0500 Subject: [PATCH 09/19] docs: replace sample digest entity names with placeholders on next (HACK-1758) Forward main's 2dd3853b. The sample digest listed entity names and a contact address from a private knowledge base. This commit is outside the HACK-1758 list; it is carried because next is public and still had the names. Co-Authored-By: Claude Opus 5.5 --- automation-verification/runtime-qa-digest.md | 40 ++------------------ 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/automation-verification/runtime-qa-digest.md b/automation-verification/runtime-qa-digest.md index ed65166d9..ffd7c7fee 100644 --- a/automation-verification/runtime-qa-digest.md +++ b/automation-verification/runtime-qa-digest.md @@ -20,45 +20,13 @@ Generated: 2026-04-20T17:09:34.679Z ## Shared KB context +_Redacted. This sample digest originally listed entity and relationship names from a private shared knowledge base. They are not needed to show the digest format and were removed._ ### Additional shared entities -- Morpheus (document) — last seen 2h ago · 3d span -- event-agent (document) — last seen 2h ago · 3d span -- markdown_projection (tag) — last seen just now · 3d span -- butt.tf (document) — last seen 22h ago · same-day signal -- Galileo (document) — last seen 1h ago · 1d span -- Godel (document) — last seen just now · 1d span -- Nietzsche (document) — last seen just now · 1d span -- Faraday (document) — last seen 1h ago -- Feynman (document) — last seen just now -- Franklin (document) — last seen 1h ago -- Hilbert (document) — last seen just now -- Maxwell (document) — last seen 1h ago -- Mendel (document) — last seen 1h ago -- mhof (document) — last seen just now -- Sartre (document) — last seen 1h ago -- Wegener (document) — last seen 1h ago -- x-trends (document) — last seen just now -- machine (document) — last seen 1d ago · 2d span -- hello@haydenbleasel.com (contact) — last seen 3d ago -- Bohr (document) — last seen 1d ago -- Boole (document) — last seen 2d ago -- Confucius (document) — last seen 2d ago -- Dirac (document) — last seen 1d ago -- Harness (document) — last seen 1d ago -- Mill (document) — last seen 1d ago -- Parfit (document) — last seen 1d ago -- Pasteur (document) — last seen 1d ago -- Russell (document) — last seen 1d ago -- Verifier (document) — last seen 1d ago -- omega (document) — last seen 3d ago · same-day signal -- Ohm (document) — last seen just now -- Processing smoke test 4/16/2026, 3:06:49 PM (document) — last seen 3d ago +- `` (document) — last seen `` · `` +- `` (contact) — last seen `` ### Additional shared relationships -- event-agent references codex — current · last seen 3d ago -- event-agent references markdown_projection — current · last seen 3d ago -- Morpheus references codex — current · last seen 3d ago -- Morpheus references markdown_projection — current · last seen 3d ago +- `` references `` — current · last seen `` From d925c5ef5cd29a088bb5d75ef623a012274c81c1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:10:14 -0500 Subject: [PATCH 10/19] test: drop a stale branch registry comment (HACK-1758) Runtime commands no longer write hack.branches.json, so the note in this test no longer describes what `up` does. Co-Authored-By: Claude Opus 5.5 --- tests/lifecycle-git-status-clean.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/lifecycle-git-status-clean.test.ts b/tests/lifecycle-git-status-clean.test.ts index c9366c161..80fac8823 100644 --- a/tests/lifecycle-git-status-clean.test.ts +++ b/tests/lifecycle-git-status-clean.test.ts @@ -181,10 +181,8 @@ test("up, restart, run, exec and down leave git status unchanged", async () => { }, 120_000); test("up and down in a linked worktree leave both checkouts clean", async () => { - // Target the base instance from the worktree. With the default - // `worktree.auto_branch`, `up` also records the derived branch in the - // authored `.hack/hack.branches.json`; that is a separate behavior from the - // ignore rules this test covers. + // Target the base instance from the worktree, because this test covers the + // ignore rules rather than branch instances. const configPath = join(repoRoot, ".hack", "hack.config.json"); const config = JSON.parse(await readFile(configPath, "utf8")); await writeFile( From 2852393cc0b2c738a54c8d456e18cde252deccf3 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:39:58 -0500 Subject: [PATCH 11/19] test: update the state guard for Bun 1.4.4 proxy deletes (HACK-1758) Bun 1.4.2 did not carry a process.env delete into its native environment, so a deleted proxy kept routing later fetch calls. Bun 1.4.4 does. The sentinel test that was written to fail on this upgrade now asserts the new behaviour, and fails if the old one returns. restoreEnv keeps its empty assignment. Co-Authored-By: Claude Opus 5.5 --- tests/global-state-guard.test.ts | 18 ++++++++++-------- tests/helpers/env.ts | 10 +++++----- tests/helpers/global-state-guard.ts | 5 +++-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/global-state-guard.test.ts b/tests/global-state-guard.test.ts index a4b479bb4..cad4aa338 100644 --- a/tests/global-state-guard.test.ts +++ b/tests/global-state-guard.test.ts @@ -327,9 +327,10 @@ test("without the guard, an early leak cascades into a later file", async () => expect(run.output).toContain( "(fail) a later file still finds executables on PATH" ); - // The deleted proxy still routes a later file's loopback fetch. + // Since Bun 1.4.4 a delete reaches the native environment, so a deleted + // proxy no longer routes a later file's loopback fetch. expect(run.output).toContain( - "(fail) a later file still reaches a loopback server directly" + "(pass) a later file still reaches a loopback server directly" ); }, 60_000); @@ -342,7 +343,8 @@ test("the guard names each leaking file, restores state, and later files pass", expect(run.output).toContain( "b-leak-in-hooks.test.ts left process state changed after the file finished: cwd changed; env HACK_HOME added" ); - expect(run.output).toContain( + // Since Bun 1.4.4 a set-then-deleted proxy leaves no native change to name. + expect(run.output).not.toContain( "c-leak-native-proxy.test.ts left Bun's native environment changed after the file finished: native env HTTP_PROXY changed." ); expect(run.output).toContain( @@ -354,7 +356,7 @@ test("the guard names each leaking file, restores state, and later files pass", expect(run.output).toContain( "(pass) a later test that emits SIGUSR2 is not charged with an earlier listener" ); - expect(run.output).toContain("3 test file(s) leaked process state"); + expect(run.output).toContain("2 test file(s) leaked process state"); expect(run.output).not.toContain("127.0.0.1:9"); expect(run.output).not.toContain("Executable not found"); expect(run.output).toContain("LATER-FILE-PASSED"); @@ -414,17 +416,17 @@ async function runRestoreEnvProbe(mode: string): Promise { return JSON.parse(stdout); } -test("restoreEnv leaves no proxy in Bun's native environment; a bare delete does", async () => { +test("neither restoreEnv nor, since Bun 1.4.4, a bare delete leaves a native proxy", async () => { expect(await runRestoreEnvProbe("restore-env")).toEqual({ js: null, set: ["HTTP_PROXY"], after: [], }); - // If a Bun upgrade makes deletes reach the native environment, this fails - // and the empty-assignment workaround in restoreEnv can go. + // Bun 1.4.2 kept the deleted key here. If a Bun upgrade brings that back, this + // fails and the empty assignment in restoreEnv is needed again. expect(await runRestoreEnvProbe("bare-delete")).toEqual({ js: null, set: ["HTTP_PROXY"], - after: ["HTTP_PROXY"], + after: [], }); }, 30_000); diff --git a/tests/helpers/env.ts b/tests/helpers/env.ts index 4c271fd43..ebbf3cf47 100644 --- a/tests/helpers/env.ts +++ b/tests/helpers/env.ts @@ -3,11 +3,11 @@ * to a `process.env` key stores the string "undefined" in Bun 1.4 (as in Node), which then leaks * into later tests in the same process, so a missing value must be deleted instead. * - * Before a delete, the key is set to "". Bun 1.4 copies assignments to the proxy keys - * (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lower-case forms) into the environment that - * `fetch` and `Bun.spawn` without `env` use, but a delete never reaches it. Without the empty - * assignment, a proxy a test set and then deleted keeps routing every later `fetch` in the - * process, including loopback requests, through that proxy (HACK-1327). An empty proxy is no proxy. + * Before a delete, the key is set to "". Bun copies assignments to the proxy keys (`HTTP_PROXY`, + * `HTTPS_PROXY`, `NO_PROXY` and their lower-case forms) into the environment that `fetch` and + * `Bun.spawn` without `env` use. Bun 1.4.2 did not carry a delete there, so a deleted proxy kept + * routing later `fetch` calls (HACK-1327). Bun 1.4.4 does; tests/global-state-guard.test.ts checks + * this, and the empty assignment stays as a harmless guard against a regression. */ export function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { diff --git a/tests/helpers/global-state-guard.ts b/tests/helpers/global-state-guard.ts index 963ba440a..35d17cd5d 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -394,8 +394,9 @@ const realSpawnSync = Bun.spawnSync.bind(Bun); * `process.env` does not show all of it. Bun 1.4 starts this environment * from the startup environment and copies later assignments to the proxy * keys (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lower-case forms) - * into it, but a delete never reaches it. A test that sets a proxy and then - * deletes it leaves `process.env` looking clean while every later `fetch` in + * into it. Under Bun 1.4.2 a delete never reached it (Bun 1.4.4 fixed this; the + * check stays to catch a regression). A test that set a proxy and then + * deleted it left `process.env` looking clean while every later `fetch` in * the process, loopback included, still goes through that proxy (HACK-1327). * `restoreEnv` in `tests/helpers/env.ts` avoids this by assigning "" first. * Other keys never change here: Bun does not copy assignments to them. From e4837dcab81cf706680f17f52acf8fad3f076f65 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:39:58 -0500 Subject: [PATCH 12/19] fix(env): keep the env layer guard ahead of inherited file checks (HACK-1758) An inherited local overlay loop must keep next's closed, value-free refusal, so the layer guard runs before validatePrimaryLocalFile. The ported test now spawns the CLI with privateCliEnv, as next's isolation scan requires. Co-Authored-By: Claude Opus 5.5 --- src/lib/project-env-config.ts | 4 ++-- tests/project-env-worktree-local.test.ts | 25 +++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 88a5cd0b8..52e753f37 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -1290,11 +1290,11 @@ async function readProjectEnvLayers(opts: { projectDir: primaryDir, envName: null, }); - await validatePrimaryLocalFile(primaryDefaultPath); await assertProjectEnvLayerPath({ projectRoot: dirname(primaryDir), path: primaryDefaultPath, }); + await validatePrimaryLocalFile(primaryDefaultPath); inheritedReads.push( await readProjectEnvConfigFile({ projectRoot: dirname(primaryDir), @@ -1308,11 +1308,11 @@ async function readProjectEnvLayers(opts: { projectDir: primaryDir, envName: selection.effectiveEnv, }); - await validatePrimaryLocalFile(primaryOverlayPath); await assertProjectEnvLayerPath({ projectRoot: dirname(primaryDir), path: primaryOverlayPath, }); + await validatePrimaryLocalFile(primaryOverlayPath); inheritedReads.push( await readProjectEnvConfigFile({ projectRoot: dirname(primaryDir), diff --git a/tests/project-env-worktree-local.test.ts b/tests/project-env-worktree-local.test.ts index fce844e90..00d8c2890 100644 --- a/tests/project-env-worktree-local.test.ts +++ b/tests/project-env-worktree-local.test.ts @@ -22,6 +22,7 @@ import { setProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; const tempDirs = new Set(); const envKeys = [ @@ -41,7 +42,9 @@ beforeEach(() => { delete process.env[key]; } // Isolate the code under test from host Git configuration as well. - Object.assign(process.env, gitIsolationEnv); + for (const [key, value] of Object.entries(gitIsolationEnv)) { + process.env[key] = value; + } }); afterEach(async () => { @@ -74,7 +77,7 @@ async function git(cwd: string, args: string[]): Promise { "init.templateDir=", ]; const result = Bun.spawnSync(["git", "-C", cwd, ...isolation, ...args], { - env: { ...process.env, ...gitIsolationEnv }, + env: { PATH: process.env.PATH ?? "", ...gitIsolationEnv }, stdout: "pipe", stderr: "pipe", }); @@ -304,11 +307,9 @@ test.each([ const target = resolve(sandbox, "redirected.yaml"); await rename(path, target); await symlink(target, path); - // On next, the effective-path layer guard may refuse the default file first. + // On next, the env layer guard refuses a link that leaves the project first. await expect(resolveProjectEnvConfig(options)).rejects.toThrow( - name === "local" - ? "Refused env layer" - : `Refusing redirected or non-regular primary local env file: ${path}` + "Refused env layer" ); }); @@ -446,11 +447,13 @@ async function runHack(cwd: string, args: string[], sandbox: string) { ["bun", resolve(import.meta.dir, "../index.ts"), ...args], { cwd, - env: { - ...process.env, - HACK_GLOBAL_CONFIG_PATH: resolve(sandbox, "global.config.json"), - HACK_NO_INTERACTIVE: "1", - }, + env: privateCliEnv({ + root: sandbox, + env: { + HACK_GLOBAL_CONFIG_PATH: resolve(sandbox, "global.config.json"), + HACK_NO_INTERACTIVE: "1", + }, + }), stdin: "ignore", stdout: "pipe", stderr: "pipe", From 32cd2edfd9d82945908743a81b5c7890bbd2e44f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:39:59 -0500 Subject: [PATCH 13/19] test(e2e): resolve the ownership journal path on each write (HACK-1758) Next's test files share one process, so a path saved by an earlier file outlived a later HACK_E2E_OWNERSHIP_DIR and the journal went elsewhere. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/docker-ownership.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/e2e/docker-ownership.ts b/tests/e2e/docker-ownership.ts index da9dceecf..a2e332ffb 100644 --- a/tests/e2e/docker-ownership.ts +++ b/tests/e2e/docker-ownership.ts @@ -95,7 +95,6 @@ export function isOwnedComposeProject(opts: { } const recordedNames = new Set(); -let journalPath: string | null = null; /** Directory holding per-process ownership journals. */ export function ownershipJournalDir(): string { @@ -105,6 +104,10 @@ export function ownershipJournalDir(): string { : join(tmpdir(), "hack-e2e-ownership"); } +function ownershipJournalPath(): string { + return join(ownershipJournalDir(), `${process.pid}.json`); +} + /** * Record a fixture project name as owned by this process. Synchronous so the * journal is on disk before any `hack up` can create resources for it. @@ -123,20 +126,16 @@ export function ownedFixtureNames(): ReadonlySet { } function writeJournal(): void { - const dir = ownershipJournalDir(); - journalPath ??= join(dir, `${process.pid}.json`); - mkdirSync(dir, { recursive: true }); + mkdirSync(ownershipJournalDir(), { recursive: true }); writeFileSync( - journalPath, + ownershipJournalPath(), `${JSON.stringify({ pid: process.pid, names: [...recordedNames] })}\n` ); } /** Remove this process's journal once its resources are verified gone. */ export async function clearOwnershipJournal(): Promise { - if (journalPath !== null) { - await rm(journalPath, { force: true }); - } + await rm(ownershipJournalPath(), { force: true }); } /** From 676bf9b76dc9197bb6ac700f2cf2d021736eef92 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:46:16 -0500 Subject: [PATCH 14/19] ci: keep RELEASE_PAT on actions/checkout on next Handing the release credential to a third-party action on a floating tag is a security decision of its own, so it isn't part of this port. Other checkout steps stay on Blacksmith. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-prepare.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index af5e941c8..3b4cc04f8 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -20,7 +20,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Checkout - uses: useblacksmith/checkout@v1 + uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f54c764da..afca8083b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,7 +200,7 @@ jobs: - name: Checkout hack uses: useblacksmith/checkout@v1 - name: Checkout tap repo - uses: useblacksmith/checkout@v1 + uses: actions/checkout@v4 with: repository: hack-dance/homebrew-tap token: ${{ secrets.RELEASE_PAT }} From ffc49b33fc2e0088318a342187b4124555a117f0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:10:15 -0500 Subject: [PATCH 15/19] fix(mcp): refuse a relative HACK_HOME or config path in sessions (HACK-1758) The server resolved these against the session cwd, but it passes the raw env to child commands, and they now refuse a relative HACK_HOME. Tool calls then failed after the server had read a different registry. A session now refuses both overrides with the CLI's error. A socket client with such a context loses only its own session. Co-Authored-By: Claude Opus 5.5 --- src/lib/home-dir.ts | 19 ++++++++- src/mcp/session-context.ts | 28 +++++++------- tests/mcp-session-context.test.ts | 64 +++++++++++++++++++++++++++---- tests/mcp-socket-backend.test.ts | 1 + 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/lib/home-dir.ts b/src/lib/home-dir.ts index 85e23eae7..e0a234d26 100644 --- a/src/lib/home-dir.ts +++ b/src/lib/home-dir.ts @@ -63,14 +63,29 @@ export function resolveUserHomeDir({ * would be as wrong as writing under the cwd. */ export function readHackHomeOverride(): string | null { - const raw = (process.env.HACK_HOME ?? "").trim(); + return readAbsolutePathOverride({ + name: "HACK_HOME", + value: process.env.HACK_HOME, + }); +} + +/** + * Read a path override from a caller's env: null when unset or whitespace-only, + * and refused when relative, because a relative value names a different place + * for each process that reads it. + */ +export function readAbsolutePathOverride(opts: { + readonly name: string; + readonly value: string | undefined; +}): string | null { + const raw = (opts.value ?? "").trim(); if (raw.length === 0) { return null; } const normalized = normalizeHomeDir(raw); if (!normalized) { throw new Error( - `HACK_HOME must be an absolute path (got a relative path: ${JSON.stringify(raw)}).` + `${opts.name} must be an absolute path (got a relative path: ${JSON.stringify(raw)}).` ); } return normalized; diff --git a/src/mcp/session-context.ts b/src/mcp/session-context.ts index 0caa5e2a3..5aa619319 100644 --- a/src/mcp/session-context.ts +++ b/src/mcp/session-context.ts @@ -1,7 +1,7 @@ import { userInfo } from "node:os"; import { dirname, resolve } from "node:path"; import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; -import { normalizeHomeDir } from "../lib/home-dir.ts"; +import { normalizeHomeDir, readAbsolutePathOverride } from "../lib/home-dir.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; export type McpSessionContext = { @@ -28,18 +28,20 @@ export function captureMcpSessionContext(opts: { ) ) ); - const override = env.HACK_HOME?.trim(); - const hackHome = override - ? resolve(cwd, override) - : resolve( - // An empty or relative HOME must not put global state under the checkout. - normalizeHomeDir(env.HOME) ?? resolve(userInfo().homedir), - ".hack" - ); - const configOverride = env.HACK_GLOBAL_CONFIG_PATH?.trim(); - const registryRoot = configOverride - ? dirname(resolve(cwd, configOverride)) - : hackHome; + // The env reaches child commands unchanged and they refuse a relative + // HACK_HOME, so the session applies the same rule instead of disagreeing. + const hackHome = + readAbsolutePathOverride({ name: "HACK_HOME", value: env.HACK_HOME }) ?? + resolve( + // An empty or relative HOME must not put global state under the checkout. + normalizeHomeDir(env.HOME) ?? resolve(userInfo().homedir), + ".hack" + ); + const configOverride = readAbsolutePathOverride({ + name: "HACK_GLOBAL_CONFIG_PATH", + value: env.HACK_GLOBAL_CONFIG_PATH, + }); + const registryRoot = configOverride ? dirname(configOverride) : hackHome; return Object.freeze({ ...(opts.admission ? { admission: opts.admission } : {}), cwd, diff --git a/tests/mcp-session-context.test.ts b/tests/mcp-session-context.test.ts index a66d4c32f..344347a47 100644 --- a/tests/mcp-session-context.test.ts +++ b/tests/mcp-session-context.test.ts @@ -12,8 +12,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { readHackHomeOverride } from "../src/lib/home-dir.ts"; import { createMcpServer } from "../src/mcp/server.ts"; import { captureMcpSessionContext } from "../src/mcp/session-context.ts"; +import { restoreEnv } from "./helpers/env.ts"; const clients: Client[] = []; const roots: string[] = []; @@ -154,22 +156,70 @@ test("MCP path authorization uses only that session's registry", async () => { ).toBe(true); }); -test("session paths resolve relative overrides against the captured checkout", () => { +test("session paths use absolute overrides", () => { const context = captureMcpSessionContext({ cwd: "/tmp/captured-session", env: { - HACK_HOME: ".state", - HACK_GLOBAL_CONFIG_PATH: ".registry/config.json", + HACK_HOME: "/tmp/hack-state", + HACK_GLOBAL_CONFIG_PATH: "/tmp/hack-registry/config.json", }, }); - expect(context.hackHome).toBe("/tmp/captured-session/.state"); - expect(context.registryPath).toBe( - "/tmp/captured-session/.registry/projects.json" - ); + expect(context.hackHome).toBe("/tmp/hack-state"); + expect(context.registryPath).toBe("/tmp/hack-registry/projects.json"); expect(Object.isFrozen(context.env)).toBe(true); expect(Object.isFrozen(context)).toBe(true); }); +test.each([ + ["HACK_HOME", ".state"], + ["HACK_HOME", "state/nested"], + ["HACK_GLOBAL_CONFIG_PATH", ".registry/config.json"], +])("a session refuses a relative %s %s like the CLI", (name, value) => { + const capture = () => + captureMcpSessionContext({ + cwd: "/tmp/captured-session", + env: { HOME: "/tmp/hack-home", [name]: value }, + }); + expect(capture).toThrow( + `${name} must be an absolute path (got a relative path: ${JSON.stringify(value)}).` + ); +}); + +test("a session refuses HACK_HOME with the CLI's exact error", () => { + const saved = process.env.HACK_HOME; + process.env.HACK_HOME = "relative-state"; + try { + let cliMessage = ""; + try { + readHackHomeOverride(); + } catch (error: unknown) { + cliMessage = error instanceof Error ? error.message : String(error); + } + expect(cliMessage).toContain("HACK_HOME must be an absolute path"); + expect(() => + captureMcpSessionContext({ + cwd: "/tmp/captured-session", + env: { HACK_HOME: "relative-state" }, + }) + ).toThrow(cliMessage); + } finally { + restoreEnv("HACK_HOME", saved); + } +}); + +test("a blank override counts as unset, as in the CLI", () => { + const context = captureMcpSessionContext({ + cwd: "/tmp/captured-session", + env: { + HOME: "/tmp/hack-home", + HACK_HOME: " ", + HACK_GLOBAL_CONFIG_PATH: "", + }, + }); + expect(context.hackHome).toBe("/tmp/hack-home/.hack"); + expect(context.registryPath).toBe("/tmp/hack-home/.hack/projects.json"); +}); + test.each([ ["empty", ""], ["whitespace", " "], diff --git a/tests/mcp-socket-backend.test.ts b/tests/mcp-socket-backend.test.ts index bdec85483..18c099763 100644 --- a/tests/mcp-socket-backend.test.ts +++ b/tests/mcp-socket-backend.test.ts @@ -185,6 +185,7 @@ test("malformed and oversized sessions close without disrupting a healthy sessio const healthy = await session(backend.socketPath, root); const badFrames = [ Buffer.from('{"hack_mcp":1,"cwd":"relative","env":{}}\n'), + Buffer.from('{"hack_mcp":1,"cwd":"/tmp","env":{"HACK_HOME":"relative"}}\n'), Buffer.from('{"hack_mcp":1,"cwd":"/tmp","env":{"X":3}}\n'), Buffer.from([0xff, 10]), Buffer.alloc(256 * 1024 + 1, 65), From 71975bb637c9e2104a68f7a43d3fbd44b52bead4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:10:16 -0500 Subject: [PATCH 16/19] docs: drop hack logs from the commands that record branch usage (HACK-1758) recordBranchUsage is called for up, down, restart, run, exec and branch add; nothing calls it for logs. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 2 +- src/lib/branch-usage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f0c031239..29cc3fa48 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -500,7 +500,7 @@ Compose-input handling is a separate qualification boundary. ### Branch registry and usage -Runtime commands never write tracked files. `hack up/down/restart/run/exec/logs` record when a +Runtime commands never write tracked files. `hack up/down/restart/run/exec` record when a branch instance was last used in machine-local `.hack/.internal/branch-usage.json` (ignored by the committed `.hack/.gitignore`, one per checkout), so `git status` stays clean in a linked worktree. diff --git a/src/lib/branch-usage.ts b/src/lib/branch-usage.ts index f0d7b9079..cd50d2b1e 100644 --- a/src/lib/branch-usage.ts +++ b/src/lib/branch-usage.ts @@ -5,7 +5,7 @@ import { getString, isRecord } from "./guards.ts"; /** * Machine-local branch usage, kept under `.hack/.internal/` so runtime - * commands (up/down/restart/run/exec/logs) never write a tracked file. + * commands (up/down/restart/run/exec) never write a tracked file. * * The committed `hack.branches.json` only holds the explicit registry * (`hack branch add/remove`). Its legacy `last_used_at` values are read as a From 30f2b1bfa0a61aeab23c143b04765ed03d1fc800 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:12:48 -0500 Subject: [PATCH 17/19] test: keep a positive native env guard case; describe the SDK limit (HACK-1758) Under Bun 1.4.4 no process.env route found leaves a hidden native change: set and delete, loadEnvFile, a Worker, libc setenv, a replaced env object. The fixture therefore presents a changed native environment through a small seam in the guard's reader. This tests the boundary check and its report, not a real Bun leak. Also reword tests/mcp-tool-result-size.test.ts: the pinned MCP SDK 1.32 has the 10 MiB read limit that its comments treated as optional. Co-Authored-By: Claude Opus 5.5 --- tests/global-state-guard.test.ts | 19 ++++++++++++++++++- tests/helpers/global-state-guard.ts | 14 ++++++++++++++ tests/mcp-tool-result-size.test.ts | 7 ++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/global-state-guard.test.ts b/tests/global-state-guard.test.ts index cad4aa338..96afa8a51 100644 --- a/tests/global-state-guard.test.ts +++ b/tests/global-state-guard.test.ts @@ -24,6 +24,7 @@ const PRELOAD = resolve( import.meta.dir, "helpers/global-state-guard-preload.ts" ); +const GUARD_HELPER = resolve(import.meta.dir, "helpers/global-state-guard.ts"); const KEY = "HACK_GLOBAL_STATE_GUARD_TEST"; const owned: string[] = []; @@ -268,6 +269,17 @@ test("a later file still reaches a loopback server directly", async () => { }); `; +// No process.env operation leaves a hidden native change under Bun 1.4.4, so this +// fixture presents one to the guard's own native reader at the file boundary. +const LEAK_NATIVE_ENV = `import { test } from "bun:test"; +import { captureNativeEnv, setNativeEnvSourceForGuardTest } from ${JSON.stringify(GUARD_HELPER)}; +test("leaves Bun's native environment changed", () => { + const changed = new Map(captureNativeEnv() ?? []); + changed.set("HTTP_PROXY", "http://127.0.0.1:9"); + setNativeEnvSourceForGuardTest(() => changed); +}); +`; + async function runNestedSuite(opts: { readonly preload: boolean; }): Promise<{ readonly code: number; readonly output: string }> { @@ -294,6 +306,7 @@ async function runNestedSuite(opts: { join(suite, "c2-leak-signal-listener.test.ts"), LEAK_LISTENER ); + await writeFile(join(suite, "c3-leak-native-env.test.ts"), LEAK_NATIVE_ENV); await writeFile(join(suite, "d-later.test.ts"), LATER); const child = Bun.spawn( [ @@ -303,6 +316,7 @@ async function runNestedSuite(opts: { "./b-leak-in-hooks.test.ts", "./c-leak-native-proxy.test.ts", "./c2-leak-signal-listener.test.ts", + "./c3-leak-native-env.test.ts", "./d-later.test.ts", ], { @@ -356,7 +370,10 @@ test("the guard names each leaking file, restores state, and later files pass", expect(run.output).toContain( "(pass) a later test that emits SIGUSR2 is not charged with an earlier listener" ); - expect(run.output).toContain("2 test file(s) leaked process state"); + expect(run.output).toContain( + "c3-leak-native-env.test.ts left Bun's native environment changed after the file finished: native env HTTP_PROXY changed" + ); + expect(run.output).toContain("3 test file(s) leaked process state"); expect(run.output).not.toContain("127.0.0.1:9"); expect(run.output).not.toContain("Executable not found"); expect(run.output).toContain("LATER-FILE-PASSED"); diff --git a/tests/helpers/global-state-guard.ts b/tests/helpers/global-state-guard.ts index 35d17cd5d..453b61f3d 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -387,6 +387,17 @@ export type NativeEnv = ReadonlyMap; // Bound at load so a leaked `Bun.spawnSync` fake can never answer for Bun. const realSpawnSync = Bun.spawnSync.bind(Bun); +let nativeEnvSourceForGuardTest: (() => NativeEnv) | undefined; + +/** + * Lets tests/global-state-guard.test.ts present a changed native environment. + * Under Bun 1.4.4 no operation it could find leaves a native change that + * `process.env` hides, and this keeps the boundary check and its report tested. + */ +export function setNativeEnvSourceForGuardTest(source: () => NativeEnv): void { + nativeEnvSourceForGuardTest = source; +} + /** * Reads Bun's native environment by running `/usr/bin/env -0` without an * `env` option, or returns undefined when that is not possible. @@ -403,6 +414,9 @@ const realSpawnSync = Bun.spawnSync.bind(Bun); * Costs one short child process; the preload runs it once per test file. */ export function captureNativeEnv(): NativeEnv | undefined { + if (nativeEnvSourceForGuardTest) { + return nativeEnvSourceForGuardTest(); + } try { const child = realSpawnSync(["/usr/bin/env", "-0"], { stdin: "ignore", diff --git a/tests/mcp-tool-result-size.test.ts b/tests/mcp-tool-result-size.test.ts index c2b2488d3..ac3c14491 100644 --- a/tests/mcp-tool-result-size.test.ts +++ b/tests/mcp-tool-result-size.test.ts @@ -20,8 +20,9 @@ import { MCP_TOOL_RESULT_TEXT_LIMIT_BYTES, } from "../src/mcp/result-budget.ts"; -/** `STDIO_DEFAULT_MAX_BUFFER_SIZE` in MCP SDK 1.32. Older SDKs have no limit, so - * these tests also measure the bytes on the wire. */ +/** `STDIO_DEFAULT_MAX_BUFFER_SIZE` in the pinned MCP SDK 1.32: past it the client + * closes the transport. These tests also measure the bytes on the wire, so a + * size regression shows as a number and not only as a lost connection. */ const SDK_CLIENT_READ_LIMIT_BYTES = 10 * 1024 * 1024; /** Everything JSON-RPC adds around `result`: `{"jsonrpc":"2.0","id":N,"result":}`. */ const ENVELOPE_ALLOWANCE_BYTES = 64; @@ -101,7 +102,7 @@ afterEach(async () => { /** * Drive the real server over stdio with a real SDK client on default limits. A * `tee` records the exact bytes the client reads, which keeps the size assertion - * meaningful on an SDK version without a read limit. + * independent of how the SDK enforces its read limit. */ async function startSession(scenario: string) { const root = await mkdtemp(join(tmpdir(), "hack-mcp-result-size-")); From 9572e4087860b44c89d00b9e3f2a32ce0e81043b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:28:16 -0500 Subject: [PATCH 18/19] fix(mcp): limit the session path refusal to HACK_HOME (HACK-1758) Child commands run in the session cwd and accept a relative HACK_GLOBAL_CONFIG_PATH, so refusing it stopped the server in a setup where the CLI works. The session resolves it against the captured cwd again, which matches them. It still refuses a relative HACK_HOME, as children do. Co-Authored-By: Claude Opus 5.5 --- src/mcp/session-context.ts | 15 ++++---- tests/mcp-session-context.test.ts | 60 +++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/mcp/session-context.ts b/src/mcp/session-context.ts index 5aa619319..7a6e38893 100644 --- a/src/mcp/session-context.ts +++ b/src/mcp/session-context.ts @@ -28,8 +28,10 @@ export function captureMcpSessionContext(opts: { ) ) ); - // The env reaches child commands unchanged and they refuse a relative - // HACK_HOME, so the session applies the same rule instead of disagreeing. + // Child commands get this env unchanged and run in this cwd, so the session + // follows their rules. They refuse a relative HACK_HOME, and so does the + // session. They accept a relative HACK_GLOBAL_CONFIG_PATH against their cwd, + // so the session resolves it against the same captured cwd. const hackHome = readAbsolutePathOverride({ name: "HACK_HOME", value: env.HACK_HOME }) ?? resolve( @@ -37,11 +39,10 @@ export function captureMcpSessionContext(opts: { normalizeHomeDir(env.HOME) ?? resolve(userInfo().homedir), ".hack" ); - const configOverride = readAbsolutePathOverride({ - name: "HACK_GLOBAL_CONFIG_PATH", - value: env.HACK_GLOBAL_CONFIG_PATH, - }); - const registryRoot = configOverride ? dirname(configOverride) : hackHome; + const configOverride = env.HACK_GLOBAL_CONFIG_PATH?.trim(); + const registryRoot = configOverride + ? dirname(resolve(cwd, configOverride)) + : hackHome; return Object.freeze({ ...(opts.admission ? { admission: opts.admission } : {}), cwd, diff --git a/tests/mcp-session-context.test.ts b/tests/mcp-session-context.test.ts index 344347a47..d8b2cb5ce 100644 --- a/tests/mcp-session-context.test.ts +++ b/tests/mcp-session-context.test.ts @@ -171,20 +171,68 @@ test("session paths use absolute overrides", () => { }); test.each([ - ["HACK_HOME", ".state"], - ["HACK_HOME", "state/nested"], - ["HACK_GLOBAL_CONFIG_PATH", ".registry/config.json"], -])("a session refuses a relative %s %s like the CLI", (name, value) => { + ".state", + "state/nested", +])("a session refuses a relative HACK_HOME %s like the CLI", (value) => { const capture = () => captureMcpSessionContext({ cwd: "/tmp/captured-session", - env: { HOME: "/tmp/hack-home", [name]: value }, + env: { HOME: "/tmp/hack-home", HACK_HOME: value }, }); expect(capture).toThrow( - `${name} must be an absolute path (got a relative path: ${JSON.stringify(value)}).` + `HACK_HOME must be an absolute path (got a relative path: ${JSON.stringify(value)}).` ); }); +test("a relative HACK_GLOBAL_CONFIG_PATH resolves against the session cwd, like its children", () => { + const context = captureMcpSessionContext({ + cwd: "/tmp/captured-session", + env: { + HOME: "/tmp/hack-home", + HACK_GLOBAL_CONFIG_PATH: ".registry/config.json", + }, + }); + expect(context.registryPath).toBe( + "/tmp/captured-session/.registry/projects.json" + ); + expect(context.hackHome).toBe("/tmp/hack-home/.hack"); +}); + +test("a session with a relative HACK_GLOBAL_CONFIG_PATH starts and reads that registry", async () => { + const { project, registryDir, state } = await fixture("relative-config"); + const relativeRegistry = join(project, ".registry"); + await mkdir(relativeRegistry); + await writeFile( + join(relativeRegistry, "projects.json"), + await readFile(join(registryDir, "projects.json")) + ); + // Without the registry above, the absolute fixture's project would be unknown. + await rm(join(registryDir, "projects.json")); + const server = createMcpServer({ + cwd: project, + env: { + ...process.env, + HOME: project, + HACK_HOME: state, + HACK_GLOBAL_CONFIG_PATH: ".registry/config.json", + HACK_MCP_COMMAND: "./command", + SYNTHETIC_SESSION_MARKER: "relative-config", + }, + }); + const client = new Client({ name: "relative-config-fixture", version: "1" }); + clients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "hack.project.status", + arguments: { projectName: "shared-name" }, + }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ ok: true }); +}); + test("a session refuses HACK_HOME with the CLI's exact error", () => { const saved = process.env.HACK_HOME; process.env.HACK_HOME = "relative-state"; From b2ca3cdc749aa6dab25c6179e66a596dd891d5e2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:28:17 -0500 Subject: [PATCH 19/19] test: make the native env guard seam answer only one read (HACK-1758) The seam stayed set for later files, which left the native check blind there. It now answers the guard's next read only, and a nested test checks that a later file gets the real reader back. The fixture name and the outer test now say that this covers detection and reporting, not the repair. Co-Authored-By: Claude Opus 5.5 --- tests/global-state-guard.test.ts | 23 ++++++++++++++++++++--- tests/helpers/global-state-guard.ts | 11 +++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/global-state-guard.test.ts b/tests/global-state-guard.test.ts index 96afa8a51..f4956b6d8 100644 --- a/tests/global-state-guard.test.ts +++ b/tests/global-state-guard.test.ts @@ -272,11 +272,20 @@ test("a later file still reaches a loopback server directly", async () => { // No process.env operation leaves a hidden native change under Bun 1.4.4, so this // fixture presents one to the guard's own native reader at the file boundary. const LEAK_NATIVE_ENV = `import { test } from "bun:test"; -import { captureNativeEnv, setNativeEnvSourceForGuardTest } from ${JSON.stringify(GUARD_HELPER)}; -test("leaves Bun's native environment changed", () => { +import { captureNativeEnv, setNativeEnvOnceForGuardTest } from ${JSON.stringify(GUARD_HELPER)}; +test("presents a changed native environment through the guard-test seam", () => { const changed = new Map(captureNativeEnv() ?? []); changed.set("HTTP_PROXY", "http://127.0.0.1:9"); - setNativeEnvSourceForGuardTest(() => changed); + setNativeEnvOnceForGuardTest(changed); +}); +`; + +// The seam answers one read, the guard's check at the boundary, so a later file +// is read by the real reader again. +const NATIVE_READER_BACK = `import { expect, test } from "bun:test"; +import { captureNativeEnv } from ${JSON.stringify(GUARD_HELPER)}; +test("a later file gets the real native reader back", () => { + expect(captureNativeEnv()?.get("HTTP_PROXY")).toBeUndefined(); }); `; @@ -307,6 +316,7 @@ async function runNestedSuite(opts: { LEAK_LISTENER ); await writeFile(join(suite, "c3-leak-native-env.test.ts"), LEAK_NATIVE_ENV); + await writeFile(join(suite, "c4-native-reader.test.ts"), NATIVE_READER_BACK); await writeFile(join(suite, "d-later.test.ts"), LATER); const child = Bun.spawn( [ @@ -317,6 +327,7 @@ async function runNestedSuite(opts: { "./c-leak-native-proxy.test.ts", "./c2-leak-signal-listener.test.ts", "./c3-leak-native-env.test.ts", + "./c4-native-reader.test.ts", "./d-later.test.ts", ], { @@ -349,6 +360,9 @@ test("without the guard, an early leak cascades into a later file", async () => }, 60_000); test("the guard names each leaking file, restores state, and later files pass", async () => { + // The native leak here comes from the seam, so no real proxy exists and the + // later loopback fetch would go direct anyway. This shows the guard detects + // and reports it, not that its repair (disableNativeProxies) works. const run = await runNestedSuite({ preload: true }); expect(run.code).not.toBe(0); expect(run.output).toContain( @@ -374,6 +388,9 @@ test("the guard names each leaking file, restores state, and later files pass", "c3-leak-native-env.test.ts left Bun's native environment changed after the file finished: native env HTTP_PROXY changed" ); expect(run.output).toContain("3 test file(s) leaked process state"); + expect(run.output).toContain( + "(pass) a later file gets the real native reader back" + ); expect(run.output).not.toContain("127.0.0.1:9"); expect(run.output).not.toContain("Executable not found"); expect(run.output).toContain("LATER-FILE-PASSED"); diff --git a/tests/helpers/global-state-guard.ts b/tests/helpers/global-state-guard.ts index 453b61f3d..4f427393a 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -387,15 +387,16 @@ export type NativeEnv = ReadonlyMap; // Bound at load so a leaked `Bun.spawnSync` fake can never answer for Bun. const realSpawnSync = Bun.spawnSync.bind(Bun); -let nativeEnvSourceForGuardTest: (() => NativeEnv) | undefined; +let nativeEnvSourceForGuardTest: NativeEnv | undefined; /** * Lets tests/global-state-guard.test.ts present a changed native environment. * Under Bun 1.4.4 no operation it could find leaves a native change that * `process.env` hides, and this keeps the boundary check and its report tested. + * It answers only the next read, so it cannot stay set for later files. */ -export function setNativeEnvSourceForGuardTest(source: () => NativeEnv): void { - nativeEnvSourceForGuardTest = source; +export function setNativeEnvOnceForGuardTest(env: NativeEnv): void { + nativeEnvSourceForGuardTest = env; } /** @@ -415,7 +416,9 @@ export function setNativeEnvSourceForGuardTest(source: () => NativeEnv): void { */ export function captureNativeEnv(): NativeEnv | undefined { if (nativeEnvSourceForGuardTest) { - return nativeEnvSourceForGuardTest(); + const presented = nativeEnvSourceForGuardTest; + nativeEnvSourceForGuardTest = undefined; + return presented; } try { const child = realSpawnSync(["/usr/bin/env", "-0"], {