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/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 f0a5be3da..5613b9295 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 @@ -111,7 +111,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 @@ -212,7 +212,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: fetch-depth: 0 - name: Install Gitleaks @@ -239,11 +239,11 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - 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 @@ -286,11 +286,11 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - 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 @@ -301,7 +301,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 @@ -399,7 +402,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. @@ -462,11 +465,11 @@ 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: - 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..7d4b46918 100644 --- a/.github/workflows/release-node-runtime-image.yml +++ b/.github/workflows/release-node-runtime-image.yml @@ -22,12 +22,12 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - 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..afca8083b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,13 +57,13 @@ jobs: tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 with: fetch-depth: 0 - 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 @@ -198,7 +198,7 @@ jobs: exit 1 fi - name: Checkout hack - uses: actions/checkout@v4 + uses: useblacksmith/checkout@v1 - name: Checkout tap repo uses: actions/checkout@v4 with: @@ -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/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/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/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/architecture.md b/docs/architecture.md index 216b8776e..39d6bed97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -370,7 +370,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` @@ -391,7 +392,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 — @@ -407,6 +409,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 fee2b1d79..3d6ce1762 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. @@ -496,6 +498,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` 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 @@ -679,7 +694,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/docs/core.md b/docs/core.md index 67875966b..173ee8ca8 100644 --- a/docs/core.md +++ b/docs/core.md @@ -56,6 +56,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 3873ccade..b3357e32a 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. @@ -1410,3 +1425,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/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/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/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..7105bf31d 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": [ @@ -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": { diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 3e5001ea6..4f975d501 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 # Zig's musl link of hack-storage-root-witness exceeds the default macOS soft 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/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/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/doctor.ts b/src/commands/doctor.ts index f5b2f027b..584b478c7 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"; @@ -45,6 +44,7 @@ import { PROJECT_ENV_CONTRACT_FILENAME, PROJECT_ENV_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"; @@ -123,6 +123,7 @@ import { inspectHackEnvOverlayWarnings, readHackEnvContract, } 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"; @@ -2833,7 +2834,11 @@ async function checkProjectEnvMaterialization({ message: "Skipped: an env layer entry is unusable (see env mode)", }; } - if (format.kind === "legacy") { + // Recorded state means modern inputs once existed; inspect it for staleness. + if ( + format.kind === "legacy" && + !(await pathExists(resolve(ctx.projectDir, PROJECT_ENV_STATE_FILENAME))) + ) { return { name: "env materialization", status: "ok", @@ -4560,7 +4565,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 31ab1f2ec..6865d482d 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"; @@ -58,6 +57,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, @@ -673,7 +673,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/project.ts b/src/commands/project.ts index ac98d784f..97ffcba1c 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -145,7 +145,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, @@ -2808,17 +2809,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({ @@ -5390,7 +5391,7 @@ async function runUpCommand({ }); } - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const remoteUpCode = await runRemoteLifecycleCommand({ project, action: "up", @@ -6064,7 +6065,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", @@ -6109,7 +6110,7 @@ async function runDownCommand({ }, }); } - await touchBranchUsageIfNeeded({ project, branch }); + await recordBranchUsageIfNeeded({ project, branch }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; const cachePruneCandidates = pruneCaches && runtime @@ -6929,7 +6930,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", @@ -7615,7 +7616,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"); @@ -7794,7 +7795,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/commands/secrets.ts b/src/commands/secrets.ts index 1429dd410..9379b0c12 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -6,7 +6,7 @@ import { confirmSafe, requireInteractive, } from "../lib/interactivity.ts"; -import { nativeKeychain as secrets } from "../lib/native-keychain.ts"; +import { keychain } from "../lib/keychain.ts"; import { logger } from "../ui/logger.ts"; const DEFAULT_SECRETS_SERVICE = "hack-cli" as const; @@ -145,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}"`, }); @@ -163,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; @@ -205,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/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/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/daemon/launchd.ts b/src/daemon/launchd.ts index 9fcb9bac6..41b01861b 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") { @@ -228,7 +230,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/branch-usage.ts b/src/lib/branch-usage.ts new file mode 100644 index 000000000..cd50d2b1e --- /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) 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/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..e0a234d26 --- /dev/null +++ b/src/lib/home-dir.ts @@ -0,0 +1,92 @@ +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 { + 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( + `${opts.name} must be an absolute path (got a relative path: ${JSON.stringify(raw)}).` + ); + } + return normalized; +} diff --git a/src/lib/keychain.ts b/src/lib/keychain.ts new file mode 100644 index 000000000..02bf033a7 --- /dev/null +++ b/src/lib/keychain.ts @@ -0,0 +1,49 @@ +import { nativeKeychain } from "./native-keychain.ts"; + +/** 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); + }, + }; +} + +/** The gate runs first, then every native call is deadline-bounded. */ +export const keychain = createKeychainBackend({ backend: nativeKeychain }); 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/project-env-config.ts b/src/lib/project-env-config.ts index b48c8dc28..9b05b04e7 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -67,6 +67,7 @@ import { resolveEnvFilePath, resolveHackEnv, } from "./hack-env.ts"; +import { resolveUserHomeDir } from "./home-dir.ts"; import { quoteShellWord } from "./logs.ts"; import { isOwnedLegacyAdoptionManagedEnvAdmission, @@ -105,7 +106,9 @@ import { withProjectsRegistryLock, } from "./projects-registry-lock.ts"; import { + hasPrimaryLocalInputs, isNativeManagedLocalTracked, + readPrimaryLocalFile, resolvePrimaryLocalProjectDir, resolveVerifiedPrimaryWorktreeRoot, resolveVerifiedProjectEnvKeyGitLocation, @@ -936,7 +939,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; } @@ -948,6 +951,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, + }); } /** @@ -1108,8 +1120,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, @@ -1386,7 +1402,7 @@ async function readProjectEnvLayers(opts: { inheritedReads.push( await readProjectEnvConfigFile({ projectRoot: dirname(primaryDir), - strictRead: opts.strictRead, + readText: readPrimaryLocalFile, path: primaryDefaultPath, environment: "default", }) @@ -1404,12 +1420,17 @@ async function readProjectEnvLayers(opts: { 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 = [ @@ -3854,12 +3875,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], }; } @@ -8303,12 +8335,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/project-meta.ts b/src/lib/project-meta.ts index 45bd453f9..466d13667 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/lib/secret-store.ts b/src/lib/secret-store.ts index 04bb9b471..bda54885c 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -15,10 +15,9 @@ import type { import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, readTextFile } from "./fs.ts"; -import { - NativeKeychainTimeoutError, - nativeKeychain as secrets, -} from "./native-keychain.ts"; +import { resolveUserHomeDir } from "./home-dir.ts"; +import { isKeychainDisabled, keychain } from "./keychain.ts"; +import { NativeKeychainTimeoutError } from "./native-keychain.ts"; import { decodeCanonicalBase64, publishPrivateFileExclusive, @@ -169,19 +168,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, }), @@ -514,9 +513,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 @@ -527,7 +525,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; @@ -535,7 +533,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: { @@ -609,19 +607,7 @@ async function resolveEncryptedFileKeyMaterial(input?: { } } - if ( - isTruthyEnvFlag(process.env[DISABLE_ENCRYPTED_FILE_KEYCHAIN_FALLBACK_ENV]) - ) { - throw new SecretStoreKeyUnavailableError( - 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, @@ -670,6 +656,16 @@ async function resolveEncryptedFileKeyMaterial(input?: { } } + if (isKeychainDisabled()) { + throw new SecretStoreKeyUnavailableError( + buildEncryptedBackendKeyRecoveryMessage({ + keyPath: configuredKeyPath, + storePath: input?.storePath, + reason: "keychain_disabled", + }) + ); + } + const nowMs = Date.now(); if (nowMs < encryptedFileKeyFailureCooldownUntilMs) { throw new SecretStoreKeyUnavailableError( @@ -684,7 +680,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, }) @@ -723,7 +719,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, @@ -1058,13 +1054,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/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/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..7a6e38893 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, readAbsolutePathOverride } from "../lib/home-dir.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; export type McpSessionContext = { @@ -27,10 +28,17 @@ export function captureMcpSessionContext(opts: { ) ) ); - const override = env.HACK_HOME?.trim(); - const hackHome = override - ? resolve(cwd, override) - : resolve(cwd, (env.HOME ?? userInfo().homedir).trim(), ".hack"); + // 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( + // 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/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/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/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 9197bc203..fab8f8c11 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. @@ -398,7 +398,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. @@ -475,7 +477,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 @@ -532,7 +534,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. @@ -582,7 +584,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`. @@ -743,7 +745,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). @@ -763,7 +781,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/docker-ownership.ts b/tests/e2e/docker-ownership.ts new file mode 100644 index 000000000..a2e332ffb --- /dev/null +++ b/tests/e2e/docker-ownership.ts @@ -0,0 +1,368 @@ +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(); + +/** 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"); +} + +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. + */ +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 { + mkdirSync(ownershipJournalDir(), { recursive: true }); + writeFileSync( + 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 { + await rm(ownershipJournalPath(), { 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 35583a0a8..af08a9521 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -2,6 +2,12 @@ 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 { recordOwnedFixture } from "./docker-ownership.ts"; import { runCommand } from "./harness.ts"; /** @@ -49,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; } /** @@ -63,8 +75,15 @@ 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(); + // 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"); @@ -92,6 +111,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/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/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/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/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/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({ 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, 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; 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-contract-shell-values.test.ts b/tests/env-contract-shell-values.test.ts index b097d28c9..980fdd180 100644 --- a/tests/env-contract-shell-values.test.ts +++ b/tests/env-contract-shell-values.test.ts @@ -31,12 +31,23 @@ const DOTENV_VALUE = ["synthetic", "dotenv", "1688"].join("-"); const OTHER_SHELL_KEY = "HACK_1688_OTHER_SYNTHETIC_TOKEN"; const ENTRYPOINT = resolve(import.meta.dir, "..", "index.ts"); const tempDirs = new Set(); +/** The preload sets this for the whole process, so a case must put it back. */ +const PRELOADED_KEYCHAIN_DISABLE = + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK; afterEach(async () => { delete process.env[SHELL_KEY]; delete process.env[OTHER_SHELL_KEY]; Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); - Reflect.deleteProperty(process.env, "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK"); + if (PRELOADED_KEYCHAIN_DISABLE === undefined) { + Reflect.deleteProperty( + process.env, + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); + } else { + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = + PRELOADED_KEYCHAIN_DISABLE; + } for (const dir of tempDirs) { await rm(dir, { recursive: true, force: true }); } @@ -646,11 +657,6 @@ for (const services of [undefined, ["api"]] as const) { expect(refused.output).not.toContain(SHELL_VALUE); expect(await snapshotTree(repo)).toEqual(before); - // Step 1 with the key still missing stops, and writes no layer. - const stillMissing = await runHack({ repo, args: exec }); - expect(stillMissing.exitCode).not.toBe(0); - expect(await snapshotTree(repo)).toEqual(before); - const withKey = { HACK_SECRETS_FILE_KEY: ["synthetic", "file", "1688"].join("-"), }; 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/global-state-guard.test.ts b/tests/global-state-guard.test.ts index a4b479bb4..f4956b6d8 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,26 @@ 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, 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"); + 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(); +}); +`; + async function runNestedSuite(opts: { readonly preload: boolean; }): Promise<{ readonly code: number; readonly output: string }> { @@ -294,6 +315,8 @@ 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, "c4-native-reader.test.ts"), NATIVE_READER_BACK); await writeFile(join(suite, "d-later.test.ts"), LATER); const child = Bun.spawn( [ @@ -303,6 +326,8 @@ 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", + "./c4-native-reader.test.ts", "./d-later.test.ts", ], { @@ -327,13 +352,17 @@ 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); 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( @@ -342,7 +371,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 +384,13 @@ 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( + "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"); @@ -414,17 +450,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..4f427393a 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -387,6 +387,18 @@ 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. + * It answers only the next read, so it cannot stay set for later files. + */ +export function setNativeEnvOnceForGuardTest(env: NativeEnv): void { + nativeEnvSourceForGuardTest = env; +} + /** * Reads Bun's native environment by running `/usr/bin/env -0` without an * `env` option, or returns undefined when that is not possible. @@ -394,14 +406,20 @@ 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. * Costs one short child process; the preload runs it once per test file. */ export function captureNativeEnv(): NativeEnv | undefined { + if (nativeEnvSourceForGuardTest) { + const presented = nativeEnvSourceForGuardTest; + nativeEnvSourceForGuardTest = undefined; + return presented; + } try { const child = realSpawnSync(["/usr/bin/env", "-0"], { stdin: "ignore", 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/keychain-guard.test.ts b/tests/keychain-guard.test.ts new file mode 100644 index 000000000..c6102ed10 --- /dev/null +++ b/tests/keychain-guard.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { secrets } from "bun"; +import { createKeychainBackend, keychain } from "../src/lib/keychain.ts"; +import { + NATIVE_KEYCHAIN_TIMEOUT_ENV, + NativeKeychainTimeoutError, +} from "../src/lib/native-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); + } +}); + +test("the shared keychain gates first, then bounds the native call", async () => { + const input = { service: "guard", name: "SECRET_TWO" }; + const savedTimeout = process.env[NATIVE_KEYCHAIN_TIMEOUT_ENV]; + const hung = spyOn(secrets, "get").mockImplementation( + () => new Promise(() => undefined) + ); + // Raced, so a missing gate or deadline fails here instead of hanging the file. + const settled = () => + Promise.race([ + keychain.get(input).catch((error: unknown) => error), + Bun.sleep(3000).then(() => "unsettled"), + ]); + try { + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "1"; + expect(String(await settled())).toContain("disabled"); + expect(hung).not.toHaveBeenCalled(); + + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "0"; + process.env[NATIVE_KEYCHAIN_TIMEOUT_ENV] = "1000"; + expect(await settled()).toBeInstanceOf(NativeKeychainTimeoutError); + expect(hung).toHaveBeenCalledTimes(1); + } finally { + hung.mockRestore(); + restoreEnv(NATIVE_KEYCHAIN_TIMEOUT_ENV, savedTimeout); + } +}); diff --git a/tests/launchd-isolation.test.ts b/tests/launchd-isolation.test.ts index f1b64f119..e621237c6 100644 --- a/tests/launchd-isolation.test.ts +++ b/tests/launchd-isolation.test.ts @@ -594,3 +594,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/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( diff --git a/tests/mcp-session-context.test.ts b/tests/mcp-session-context.test.ts index 0c6423df0..d8b2cb5ce 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,20 +156,129 @@ 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_HOME: "/tmp/hack-state", + HACK_GLOBAL_CONFIG_PATH: "/tmp/hack-registry/config.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([ + ".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", HACK_HOME: value }, + }); + expect(capture).toThrow( + `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.hackHome).toBe("/tmp/captured-session/.state"); expect(context.registryPath).toBe( "/tmp/captured-session/.registry/projects.json" ); - expect(Object.isFrozen(context.env)).toBe(true); - expect(Object.isFrozen(context)).toBe(true); + 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"; + 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", " "], + ["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 () => { 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), 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-")); 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/project-env-worktree-local.test.ts b/tests/project-env-worktree-local.test.ts new file mode 100644 index 000000000..00d8c2890 --- /dev/null +++ b/tests/project-env-worktree-local.test.ts @@ -0,0 +1,692 @@ +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"; +import { privateCliEnv } from "./helpers/private-cli-env.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. + for (const [key, value] of Object.entries(gitIsolationEnv)) { + process.env[key] = value; + } +}); + +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: { PATH: process.env.PATH ?? "", ...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 env layer guard refuses a link that leaves the project first. + await expect(resolveProjectEnvConfig(options)).rejects.toThrow( + "Refused env layer" + ); +}); + +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: privateCliEnv({ + root: sandbox, + 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 + ); +}); diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index d251f39a3..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"; @@ -193,6 +194,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"); } @@ -734,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); + } + }); +}