From c2d4766d743cb0d778673cc218c87036321004c6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 7 Sep 2026 17:03:42 +0000 Subject: [PATCH 01/57] chore(repo): enforce commitlint scope-enum tied to turbo projects (#6496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Tooling/chore — adds commitlint, tied to a fixed scope list. ## What is the current behavior? There is no commitlint or commitizen setup. PR titles must follow conventional-commits format (checked in CI by `amannn/action-semantic-pull-request`), but scopes are unrestricted free text, enforced only by human review. ## What is the new behavior? - Adds `commitlint.config.js` with a `scope-enum` rule: one scope per real turbo/pnpm workspace project (`api`, `cli`, `cli-e2e`, `cli-go`, `cli-test-helpers`, `config`, `docs`, `process-compose`, `stack` — verified against `pnpm -r list`) plus escape scopes for changes that don't map to a single project (`ci`, `repo`, `misc`, `release`). The 8 per-platform `packages/cli-{platform}` binary-wrapper packages are folded into `cli`. `release` isn't a turbo project (`tools/release` has no `package.json`) but is kept as an escape scope because `propose-release-notes.ts` genuinely commits with that scope. - Adds a local `commit-msg` git hook via husky that runs commitlint on every commit. CI checkouts skip installing this hook (`HUSKY=0` in the shared setup action) so bot-authored commits are never gated by it — scope enforcement for those stays on the PR-title CI check. - Mirrors the same scope list into the `amannn/action-semantic-pull-request` step in `lint-pull-request.yml`, so PR titles are held to the same list in CI. - Updates `.github/dependabot.yml`: the `npm` and `docker` ecosystems previously auto-generated scopes (`deps`/`deps-dev`/`docker`) that aren't in the new fixed list, which would have started failing their own PR-title check — both remapped to `misc`. The `gomod` ecosystem had no `commit-message` config at all, so it fell back to a repository-detected scope also outside the allowlist — mapped to `chore(cli-go): ` since that's precisely the project those updates belong to. `github-actions` (already `ci`) is untouched. - Documents the local commit-msg hook in `CONTRIBUTING.md`, and adds one clarifying sentence to `AGENTS.md` pointing at `commitlint.config.js` as the source of truth for allowed scopes. No commitizen/interactive prompt added — commitlint validates whatever message is typed. --- .github/actions/setup/action.yml | 5 + .github/dependabot.yml | 9 +- .github/workflows/lint-pull-request.yml | 16 + .husky/commit-msg | 1 + AGENTS.md | 2 +- CONTRIBUTING.md | 2 + commitlint.config.js | 23 ++ package.json | 5 +- pnpm-lock.yaml | 419 ++++++++++++++++++------ 9 files changed, 374 insertions(+), 108 deletions(-) create mode 100755 .husky/commit-msg create mode 100644 commitlint.config.js diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d5c4e069b7..1fdd077f3b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -75,6 +75,11 @@ runs: shell: bash env: DEPENDENCY_FIREWALL_TOKEN: ${{ inputs.dependency-firewall-token }} + # Skip husky's hook install: the commit-msg hook is a local dev + # convenience, not a gate for bot-authored commits (e.g. + # propose-release-notes.ts), and CI already enforces scopes on the + # PR title separately. + HUSKY: "0" run: | if [ -z "$DEPENDENCY_FIREWALL_TOKEN" ]; then echo "Dependency Firewall token unavailable; using default npm registry." diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 547e80b469..cd48946764 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,8 @@ updates: schedule: interval: "cron" cronjob: "0 0 * * *" + commit-message: + prefix: "chore(cli-go): " groups: go-minor: update-types: @@ -43,9 +45,8 @@ updates: interval: "cron" cronjob: "0 0 * * *" commit-message: - prefix: "fix" - prefix-development: "chore" - include: "scope" + prefix: "fix(misc): " + prefix-development: "chore(misc): " groups: npm-major: patterns: @@ -58,7 +59,7 @@ updates: interval: "cron" cronjob: "0 0 * * *" commit-message: - prefix: "fix(docker): " + prefix: "fix(misc): " groups: docker-minor: update-types: diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index c70377a830..2fd8aad379 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -44,5 +44,21 @@ jobs: uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Keep this scope list in sync with commitlint.config.js. + with: + scopes: | + api + cli + cli-e2e + cli-go + cli-test-helpers + config + docs + process-compose + stack + ci + repo + misc + release - if: github.event_name == 'merge_group' run: echo "Merge queue entry does not have a pull request payload; reporting success for the required lint check." diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000000..2e6b87e2fc --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec commitlint --edit "$1" diff --git a/AGENTS.md b/AGENTS.md index 9e313dc366..a41624df94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,7 +272,7 @@ scripts, which delegate orchestration to Turbo. ## Pull Requests -PR titles must follow conventional-commits format because the `Lint Pull Request` workflow runs `amannn/action-semantic-pull-request` against the title. Use `(): ` (e.g. `fix(cli): …`, `test(cli): …`, `feat(api): …`). A bare descriptive title like "Build TypeScript CLI as compiled Bun binaries" will fail the lint. When a PR is created (including by the Claude Code UI or someone else), check the title against this rule and update it if needed. +PR titles must follow conventional-commits format because the `Lint Pull Request` workflow runs `amannn/action-semantic-pull-request` against the title. Use `(): ` (e.g. `fix(cli): …`, `test(cli): …`, `feat(api): …`). A bare descriptive title like "Build TypeScript CLI as compiled Bun binaries" will fail the lint. The allowed scopes are enforced by `commitlint.config.js` and mirrored in `.github/workflows/lint-pull-request.yml`. When a PR is created (including by the Claude Code UI or someone else), check the title against this rule and update it if needed. Avoid semantic-release-triggering types for non-release changes. For CI, docs, tests, tooling, agent instructions, and other repository-maintenance changes, do not use `fix`, `feat`, `perf`, or breaking-change markers just to satisfy the PR title linter. Prefer non-releasing conventional types such as `chore`, `docs`, `test`, or `ci` when the change should not produce a package release. Do not include a validation, test plan, or list of checks in PR descriptions. CI enforces validation for PRs, so PR descriptions should focus on what changed, why it changed, and any reviewer-relevant context that CI cannot infer. This repo is public: PR descriptions, issues, and code comments are world-readable. Keep internal content out of them: absolute production metrics (event counts, user counts, revenue figures: state percentages, ratios, or relative change instead), internal decision detail (vendor, legal, pricing, or strategy discussions), and competitor names (protocol identifiers such as user-agent strings are fine). Put that context in the Linear issue and link it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1656972dfd..1b20df8868 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,8 @@ Install workspace dependencies: pnpm install ``` +This also installs a local `commit-msg` git hook (via husky) that validates your commit messages against `commitlint.config.js` — see [Pull Requests](AGENTS.md#pull-requests) for the allowed types and scopes. + Clone the reference submodules used during development: ```sh diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000000..456a99269a --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,23 @@ +const PROJECT_SCOPES = [ + "api", + "cli", + "cli-e2e", + "cli-go", + "cli-test-helpers", + "config", + "docs", + "process-compose", + "stack", +]; + +// Non-project changes that don't map to a single turbo project. `release` +// isn't a turbo project (tools/release has no package.json) but is a real +// scope emitted by the automated release-notes-proposal commit. +const ESCAPE_SCOPES = ["ci", "repo", "misc", "release"]; + +module.exports = { + extends: ["@commitlint/config-conventional"], + rules: { + "scope-enum": [2, "always", [...PROJECT_SCOPES, ...ESCAPE_SCOPES]], + }, +}; diff --git a/package.json b/package.json index 6aef9409f4..d4f9a4ed6f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@supabase/root", "private": true, "scripts": { - "prepare": "effect-tsgo patch --no-typescript --oxlint", + "prepare": "effect-tsgo patch --no-typescript --oxlint && husky", "build": "pnpm exec turbo run build", "generate": "pnpm exec turbo run @supabase/api#generate && pnpm exec turbo run @supabase/docs#generate", "test:live": "pnpm exec turbo run supabase#test:live --concurrency=1 --", @@ -29,9 +29,12 @@ "cli-release": "bun tools/release/local-release.ts" }, "devDependencies": { + "@commitlint/cli": "^21.2.2", + "@commitlint/config-conventional": "^21.2.2", "@effect/tsgo": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "husky": "^9.1.7", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ab6608359..19425091a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,12 @@ importers: .: devDependencies: + '@commitlint/cli': + specifier: ^21.2.2 + version: 21.2.2(@types/node@26.3.0)(conventional-commits-parser@7.1.2)(typescript@7.0.2) + '@commitlint/config-conventional': + specifier: ^21.2.2 + version: 21.2.2 '@effect/tsgo': specifier: 'catalog:' version: 0.37.0 @@ -183,6 +189,9 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.4.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 knip: specifier: 'catalog:' version: 6.32.2 @@ -209,7 +218,7 @@ importers: version: 6.10.0(supports-color@7.2.0)(typanion@3.14.0) vite: specifier: ^8.2.2 - version: 8.2.2 + version: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) apps/cli: dependencies: @@ -362,7 +371,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) apps/cli-go: {} @@ -426,13 +435,13 @@ importers: version: 1.4.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.11(vite@8.2.2(@types/node@26.3.0))(vitest@4.1.11) + version: 4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) typescript: specifier: 'catalog:' version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-darwin-arm64: {} @@ -456,13 +465,13 @@ importers: version: 1.4.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.11(vite@8.2.2)(vitest@4.1.11) + version: 4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) typescript: specifier: 'catalog:' version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages/cli-windows-arm64: {} @@ -528,7 +537,7 @@ importers: version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vite@8.2.2)(vitest@4.1.11) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -537,13 +546,13 @@ importers: version: 1.4.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.11(vite@8.2.2)(vitest@4.1.11) + version: 4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) typescript: specifier: 'catalog:' version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages/stack: dependencies: @@ -752,6 +761,91 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@commitlint/cli@21.2.2': + resolution: {integrity: sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@commitlint/config-conventional@21.2.2': + resolution: {integrity: sha512-NxA37SZviusFUEYOQZ5hNnZ1h7O/KiemPkxjOlpzKJNnWxThiwc6/SaZhaPa8fyLvfRBAywhQhJJk8XESHWlpQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/config-validator@21.2.0': + resolution: {integrity: sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==} + engines: {node: '>=22.12.0'} + + '@commitlint/ensure@21.2.0': + resolution: {integrity: sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==} + engines: {node: '>=22.12.0'} + + '@commitlint/execute-rule@21.0.1': + resolution: {integrity: sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==} + engines: {node: '>=22.12.0'} + + '@commitlint/format@21.2.2': + resolution: {integrity: sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA==} + engines: {node: '>=22.12.0'} + + '@commitlint/is-ignored@21.2.2': + resolution: {integrity: sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg==} + engines: {node: '>=22.12.0'} + + '@commitlint/lint@21.2.2': + resolution: {integrity: sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg==} + engines: {node: '>=22.12.0'} + + '@commitlint/load@21.2.2': + resolution: {integrity: sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg==} + engines: {node: '>=22.12.0'} + + '@commitlint/message@21.2.0': + resolution: {integrity: sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==} + engines: {node: '>=22.12.0'} + + '@commitlint/parse@21.2.2': + resolution: {integrity: sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg==} + engines: {node: '>=22.12.0'} + + '@commitlint/read@21.2.1': + resolution: {integrity: sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==} + engines: {node: '>=22.12.0'} + + '@commitlint/resolve-extends@21.2.2': + resolution: {integrity: sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A==} + engines: {node: '>=22.12.0'} + + '@commitlint/rules@21.2.2': + resolution: {integrity: sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA==} + engines: {node: '>=22.12.0'} + + '@commitlint/to-lines@21.0.1': + resolution: {integrity: sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==} + engines: {node: '>=22.12.0'} + + '@commitlint/top-level@21.2.0': + resolution: {integrity: sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/types@21.2.0': + resolution: {integrity: sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==} + engines: {node: '>=22.12.0'} + + '@conventional-changelog/git-client@3.1.2': + resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} + engines: {node: '>=22'} + peerDependencies: + conventional-commits-filter: ^6.0.1 + conventional-commits-parser: ^7.1.2 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@conventional-changelog/template@1.4.0': + resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==} + engines: {node: '>=22'} + '@cypress/request@4.0.1': resolution: {integrity: sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==} engines: {node: '>= 14.17.0'} @@ -2611,10 +2705,18 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-libs/child-process-utils@2.0.0': + resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} + engines: {node: '>=22'} + '@simple-libs/stream-utils@1.2.0': resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} + '@simple-libs/stream-utils@2.0.0': + resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} + engines: {node: '>=22'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -3175,6 +3277,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argue-cli@3.1.0: + resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} + engines: {node: '>=22'} + argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} @@ -3525,6 +3631,14 @@ packages: resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} engines: {node: '>=18'} + conventional-changelog-angular@9.4.0: + resolution: {integrity: sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==} + engines: {node: '>=22'} + + conventional-changelog-conventionalcommits@10.4.0: + resolution: {integrity: sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==} + engines: {node: '>=22'} + conventional-changelog-writer@8.4.0: resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} engines: {node: '>=18'} @@ -3539,6 +3653,11 @@ packages: engines: {node: '>=18'} hasBin: true + conventional-commits-parser@7.1.2: + resolution: {integrity: sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==} + engines: {node: '>=22'} + hasBin: true + convert-hrtime@5.0.0: resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} engines: {node: '>=12'} @@ -3567,6 +3686,14 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + cosmiconfig@9.0.2: resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} engines: {node: '>=14'} @@ -3760,6 +3887,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -4168,6 +4298,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -4305,6 +4439,11 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iceberg-js@0.8.1: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} @@ -4349,6 +4488,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4456,6 +4599,10 @@ packages: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -6785,6 +6932,125 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@commitlint/cli@21.2.2(@types/node@26.3.0)(conventional-commits-parser@7.1.2)(typescript@7.0.2)': + dependencies: + '@commitlint/config-conventional': 21.2.2 + '@commitlint/format': 21.2.2 + '@commitlint/lint': 21.2.2 + '@commitlint/load': 21.2.2(@types/node@26.3.0)(typescript@7.0.2) + '@commitlint/read': 21.2.1(conventional-commits-parser@7.1.2) + '@commitlint/types': 21.2.0 + tinyexec: 1.3.0 + yargs: 18.1.0 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@21.2.2': + dependencies: + '@commitlint/types': 21.2.0 + conventional-changelog-conventionalcommits: 10.4.0 + + '@commitlint/config-validator@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + ajv: 8.20.0 + + '@commitlint/ensure@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + es-toolkit: 1.52.0 + + '@commitlint/execute-rule@21.0.1': {} + + '@commitlint/format@21.2.2': + dependencies: + '@commitlint/types': 21.2.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@21.2.2': + dependencies: + '@commitlint/types': 21.2.0 + semver: 7.8.5 + + '@commitlint/lint@21.2.2': + dependencies: + '@commitlint/is-ignored': 21.2.2 + '@commitlint/parse': 21.2.2 + '@commitlint/rules': 21.2.2 + '@commitlint/types': 21.2.0 + + '@commitlint/load@21.2.2(@types/node@26.3.0)(typescript@7.0.2)': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/execute-rule': 21.0.1 + '@commitlint/resolve-extends': 21.2.2 + '@commitlint/types': 21.2.0 + cosmiconfig: 9.0.2(typescript@7.0.2) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.3.0)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2) + es-toolkit: 1.52.0 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@21.2.0': {} + + '@commitlint/parse@21.2.2': + dependencies: + '@commitlint/types': 21.2.0 + conventional-changelog-angular: 9.4.0 + conventional-commits-parser: 7.1.2 + + '@commitlint/read@21.2.1(conventional-commits-parser@7.1.2)': + dependencies: + '@commitlint/top-level': 21.2.0 + '@commitlint/types': 21.2.0 + '@conventional-changelog/git-client': 3.1.2(conventional-commits-parser@7.1.2) + tinyexec: 1.3.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@21.2.2': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/types': 21.2.0 + es-toolkit: 1.52.0 + global-directory: 5.0.0 + resolve-from: 5.0.0 + + '@commitlint/rules@21.2.2': + dependencies: + '@commitlint/ensure': 21.2.0 + '@commitlint/message': 21.2.0 + '@commitlint/to-lines': 21.0.1 + '@commitlint/types': 21.2.0 + + '@commitlint/to-lines@21.0.1': {} + + '@commitlint/top-level@21.2.0': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@21.2.0': + dependencies: + conventional-commits-parser: 7.1.2 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': + dependencies: + '@simple-libs/child-process-utils': 2.0.0 + '@simple-libs/stream-utils': 2.0.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 7.1.2 + + '@conventional-changelog/template@1.4.0': {} + '@cypress/request@4.0.1': dependencies: aws-sign2: 0.7.0 @@ -6885,12 +7151,6 @@ snapshots: vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) - '@effect/vitest@4.0.0-rc.112(effect@4.0.0-rc.112)(vite@8.2.2)(vitest@4.1.11)': - dependencies: - effect: 4.0.0-rc.112 - vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) - vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) - '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -8279,8 +8539,14 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simple-libs/child-process-utils@2.0.0': + dependencies: + '@simple-libs/stream-utils': 2.0.0 + '@simple-libs/stream-utils@1.2.0': {} + '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@8.1.0': {} @@ -8686,36 +8952,6 @@ snapshots: vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/coverage-v8@4.1.11(vite@8.2.2(@types/node@26.3.0))(vitest@4.1.11)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.11 - ast-v8-to-istanbul: 1.0.5 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.4 - obug: 2.1.4 - std-env: 4.2.0 - tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) - vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)) - - '@vitest/coverage-v8@4.1.11(vite@8.2.2)(vitest@4.1.11)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.11 - ast-v8-to-istanbul: 1.0.5 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.4 - obug: 2.1.4 - std-env: 4.2.0 - tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) - vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) - '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -8725,7 +8961,7 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2)': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 @@ -8868,6 +9104,8 @@ snapshots: argparse@2.0.1: {} + argue-cli@3.1.0: {} + argv-formatter@1.0.0: {} aria-hidden@1.2.6: @@ -9201,6 +9439,14 @@ snapshots: dependencies: compare-func: 2.0.0 + conventional-changelog-angular@9.4.0: + dependencies: + '@conventional-changelog/template': 1.4.0 + + conventional-changelog-conventionalcommits@10.4.0: + dependencies: + '@conventional-changelog/template': 1.4.0 + conventional-changelog-writer@8.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -9216,6 +9462,11 @@ snapshots: '@simple-libs/stream-utils': 1.2.0 meow: 13.2.0 + conventional-commits-parser@7.1.2: + dependencies: + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.1.0 + convert-hrtime@5.0.0: {} convert-source-map@2.0.0: {} @@ -9235,6 +9486,13 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig-typescript-loader@6.3.0(@types/node@26.3.0)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2): + dependencies: + '@types/node': 26.3.0 + cosmiconfig: 9.0.2(typescript@7.0.2) + jiti: 2.6.1 + typescript: 7.0.2 + cosmiconfig@9.0.2(typescript@7.0.2): dependencies: env-paths: 2.2.1 @@ -9403,6 +9661,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.52.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -9915,6 +10175,10 @@ snapshots: dependencies: is-glob: 4.0.3 + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -10155,6 +10419,8 @@ snapshots: human-signals@8.0.1: {} + husky@9.1.7: {} + iceberg-js@0.8.1: {} iconv-lite@0.4.24: @@ -10191,6 +10457,8 @@ snapshots: ini@1.3.8: {} + ini@6.0.0: {} + inline-style-parser@0.2.7: {} ip-address@10.5.0: {} @@ -10269,6 +10537,8 @@ snapshots: java-properties@1.0.2: {} + jiti@2.6.1: {} + jiti@2.7.0: {} jose@6.2.10: {} @@ -12651,7 +12921,7 @@ snapshots: vitest@4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -12676,61 +12946,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.3.0)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 26.3.0 - '@vitest/coverage-v8': 4.1.11(vite@8.2.2(@types/node@26.3.0))(vitest@4.1.11) - transitivePeerDependencies: - - msw - - vitest@4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@vitest/coverage-v8': 4.1.11(vite@8.2.2)(vitest@4.1.11) - transitivePeerDependencies: - - msw - walk-up-path@4.0.0: {} web-namespaces@2.0.1: {} From a9886ec77c8b3862e763797339658c05037b3fb2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 7 Sep 2026 17:03:53 +0000 Subject: [PATCH 02/57] fix(cli): explicit --workdir must not climb to a parent project (CLI-2285) (#6497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary An explicit `--workdir`/`SUPABASE_WORKDIR` could silently let `loadCliConfig`/`findCliProjectRoot` climb ancestor directories to find `supabase/config.{toml,json}` — so `--workdir ./sub` where `sub/supabase/` doesn't exist could silently load, or **push**, an unrelated parent project's config instead of failing. A defaulted (unset) workdir still climbs exactly as before. Linear: [CLI-2285](https://linear.app/supabase/issue/CLI-2285/explicit-workdir-must-not-climb-to-a-parent-project-config-diffpush). ## What changed - `LegacyCliSettings` gains `explicitWorkdir: boolean`; a new `legacyShouldSearchAncestors(cliSettings)` helper (`command-internal/legacy-workdir-search.ts`) gates the ancestor search at every affected call site: `config diff/push/pull`, `gen types`, `seed buckets`, `storage ls/mv/rm/cp`, `functions new`, `experimental workers`, and `functions serve/deploy`. - `packages/config`'s `findCliProjectRoot` gains an optional `FindCliProjectPathsOptions` parameter (additive), matching `findCliProjectPaths`'s existing `search: false` support. - `config diff/push/pull`, `gen types`, `storage`, and `seed buckets` now hard-fail with a clear error instead of silently falling back to embedded defaults (or an unrelated ancestor's config) when an explicit workdir has no project. - The same commands now validate the workdir is an existing directory up front (reusing the existing `start`/`stop`/`status` pattern), so a typo'd path fails with "no such directory" instead of a confusing "file not found". - The missing-project message (`command-internal/legacy-workdir-project.ts`) no longer suggests `supabase init` for an explicit workdir — which could scaffold a fresh config at the wrong path, leading to a subsequent `push` overwriting the real project — and instead names the resolved path, suggesting an ancestor's path when one genuinely has a project. - `gen types` no longer leaks a raw `CliConfigParseError` tag as its error message on a malformed config. - `experimental workers new` gained the same workdir-existence guard `functions new` already had, closing an identical scaffold-at-a-nonexistent-path gap. ## Follow-ups filed separately (explicitly out of scope here) - `secrets set` ignores `--workdir` entirely (loads from `runtimeInfo.cwd`). - Extending the explicit-workdir hard-fail policy to the `db`/`migration` TOML-only loaders. - `--debug` workdir logging, `--workdir` help-text tightening, and a couple of smaller consistency nits (error-code unification across the `config` family, `workers push`'s hardcoded error paths). --- .../legacy-platform-api.layer.unit.test.ts | 1 + ...e-runtime-script.layer.integration.test.ts | 1 + .../command-internal/legacy-seed-buckets.ts | 10 +- .../legacy-workdir-project.ts | 125 ++++++++++++ .../legacy-workdir-project.unit.test.ts | 33 ++++ .../command-internal/legacy-workdir-search.ts | 37 ++++ apps/cli/src/commands/config/config.load.ts | 51 +++-- .../src/commands/config/diff/SIDE_EFFECTS.md | 39 ++-- .../src/commands/config/diff/diff.errors.ts | 13 ++ .../src/commands/config/diff/diff.handler.ts | 16 +- .../config/diff/diff.integration.test.ts | 101 +++++++++- .../src/commands/config/pull/SIDE_EFFECTS.md | 21 +- .../src/commands/config/pull/pull.errors.ts | 14 ++ .../src/commands/config/pull/pull.handler.ts | 30 ++- .../config/pull/pull.integration.test.ts | 72 ++++++- .../src/commands/config/push/SIDE_EFFECTS.md | 63 +++--- .../src/commands/config/push/push.errors.ts | 14 ++ .../src/commands/config/push/push.handler.ts | 101 +++++++--- .../config/push/push.integration.test.ts | 134 ++++++++++++- .../workers/delete/SIDE_EFFECTS.md | 34 ++-- .../experimental/workers/list/SIDE_EFFECTS.md | 32 +-- .../workers/list/list.integration.test.ts | 60 ++++++ .../experimental/workers/new/SIDE_EFFECTS.md | 24 ++- .../experimental/workers/new/new.errors.ts | 22 +++ .../experimental/workers/new/new.handler.ts | 54 +++++- .../workers/new/new.integration.test.ts | 95 ++++++++- .../experimental/workers/push/SIDE_EFFECTS.md | 26 +-- .../workers/status/SIDE_EFFECTS.md | 32 +-- .../experimental/workers/workers.shared.ts | 36 ++-- .../commands/functions/deploy/SIDE_EFFECTS.md | 24 +-- .../deploy/deploy.integration.test.ts | 91 +++++++++ .../commands/functions/new/SIDE_EFFECTS.md | 14 +- .../src/commands/functions/new/new.errors.ts | 17 ++ .../src/commands/functions/new/new.handler.ts | 21 +- .../functions/new/new.integration.test.ts | 35 +++- .../commands/functions/serve/SIDE_EFFECTS.md | 1 + .../functions/serve/serve.integration.test.ts | 92 +++++++++ .../src/commands/gen/types/SIDE_EFFECTS.md | 65 ++++--- .../src/commands/gen/types/types.errors.ts | 49 +++++ .../src/commands/gen/types/types.handler.ts | 88 ++++++++- .../gen/types/types.integration.test.ts | 183 ++++++++++++++++++ .../src/commands/seed/buckets/SIDE_EFFECTS.md | 18 +- .../commands/seed/buckets/buckets.errors.ts | 30 +++ .../commands/seed/buckets/buckets.handler.ts | 29 ++- .../seed/buckets/buckets.integration.test.ts | 97 +++++++++- .../services/services.integration.test.ts | 1 + .../src/commands/storage/cp/SIDE_EFFECTS.md | 18 +- .../cli/src/commands/storage/cp/cp.handler.ts | 10 +- .../src/commands/storage/ls/SIDE_EFFECTS.md | 26 +-- .../cli/src/commands/storage/ls/ls.handler.ts | 5 +- .../storage/ls/ls.integration.test.ts | 101 ++++++++++ .../src/commands/storage/mv/SIDE_EFFECTS.md | 16 +- .../cli/src/commands/storage/mv/mv.handler.ts | 5 +- .../src/commands/storage/rm/SIDE_EFFECTS.md | 16 +- .../cli/src/commands/storage/rm/rm.handler.ts | 5 +- .../storage/rm/rm.integration.test.ts | 93 +++++++++ .../src/commands/storage/storage.errors.ts | 29 +++ .../cli/src/commands/storage/storage.frame.ts | 55 +++++- .../src/config/legacy-cli-settings.layer.ts | 26 ++- .../legacy-cli-settings.layer.unit.test.ts | 44 +++++ .../src/config/legacy-cli-settings.service.ts | 17 ++ .../legacy-project-ref.layer.unit.test.ts | 1 + apps/cli/src/shared/functions/deploy.ts | 6 + apps/cli/src/shared/functions/serve.ts | 40 ++-- apps/cli/src/shared/legacy/global-flags.ts | 4 +- apps/cli/tests/helpers/legacy-mocks.ts | 2 + apps/cli/tests/helpers/legacy-storage.ts | 4 +- apps/cli/tests/helpers/legacy-workers.ts | 14 +- packages/config/src/paths.ts | 7 +- 69 files changed, 2331 insertions(+), 359 deletions(-) create mode 100644 apps/cli/src/command-internal/legacy-workdir-project.ts create mode 100644 apps/cli/src/command-internal/legacy-workdir-project.unit.test.ts create mode 100644 apps/cli/src/command-internal/legacy-workdir-search.ts create mode 100644 apps/cli/src/commands/experimental/workers/new/new.errors.ts diff --git a/apps/cli/src/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/auth/legacy-platform-api.layer.unit.test.ts index 71a617e3be..8b57ef9c82 100644 --- a/apps/cli/src/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/legacy-platform-api.layer.unit.test.ts @@ -42,6 +42,7 @@ function mockCliSettings(opts: { opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), workdir: "/tmp", + explicitWorkdir: false, userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", }); } diff --git a/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.integration.test.ts index ae1cc9dd44..f238768a18 100644 --- a/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.integration.test.ts @@ -52,6 +52,7 @@ function makeCliSettings(workdir = "/nonexistent-workdir") { accessToken: Option.none(), projectId: Option.none(), workdir, + explicitWorkdir: false, userAgent: "test", }); } diff --git a/apps/cli/src/command-internal/legacy-seed-buckets.ts b/apps/cli/src/command-internal/legacy-seed-buckets.ts index 4d0d4f7476..85dce17e57 100644 --- a/apps/cli/src/command-internal/legacy-seed-buckets.ts +++ b/apps/cli/src/command-internal/legacy-seed-buckets.ts @@ -9,6 +9,7 @@ import { legacyResolveYesWithProjectEnv } from "../shared/legacy/global-flags.ts import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyBold, legacyYellow } from "./legacy-colors.ts"; import { legacyLoadProjectEnv } from "./legacy-db-config.toml-read.ts"; +import { legacyShouldSearchAncestors } from "./legacy-workdir-search.ts"; import { legacyPromptYesNo } from "../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyResolveStorageCredentials, @@ -188,7 +189,9 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // when the caller already supplied `resolvedConfig` — see that option's doc // comment above. const loadOptions: InternalLoadCliConfigOptions = - projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; + projectRef !== "" + ? { projectRef, goViperCompat: true, search: legacyShouldSearchAncestors(cliSettings) } + : { goViperCompat: true, search: legacyShouldSearchAncestors(cliSettings) }; const loaded = opts.resolvedConfig !== undefined ? null @@ -206,6 +209,11 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // into the no-op short-circuit; `--linked` + no-config falls through to the // remote path so auth/project/API failures surface. `resolvedConfig` (when // given) always wins over a `null` `loaded` — see that option's doc comment. + // The standalone `seed buckets` command now rejects an explicit-but-project-less + // workdir in its own handler (`buckets.handler.ts`, + // `legacyRequireExplicitWorkdirProject`) before ever reaching this function, so + // this fallback is reached only for a DEFAULTED workdir, or a `resolvedConfig` + // caller (`start`/`db reset`, which never load config here at all). const config = opts.resolvedConfig?.config ?? (loaded === null ? legacyDecodeDefaultCliConfig({}) : loaded.config); diff --git a/apps/cli/src/command-internal/legacy-workdir-project.ts b/apps/cli/src/command-internal/legacy-workdir-project.ts new file mode 100644 index 0000000000..4fa52f5627 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-workdir-project.ts @@ -0,0 +1,125 @@ +import { findCliProjectPaths } from "@supabase/config/effect"; +import { Data, Effect } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import { legacySanitizeInlineName } from "./legacy-http-errors.ts"; + +/** + * `cause.path`/`loaded.path` are anchored under `workdir`; render them + * relative so a message reads `supabase/config.json` like the rest of the + * JSON-capable config-load family (`config diff`/`pull`/`push`, `gen types`), + * regardless of invocation cwd. + */ +export function legacyRelativeConfigPath(workdir: string, path: string): string { + return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; +} + +/** + * The established "no project here" message for a JSON-capable config load + * (`config diff`/`push`/`pull`, `gen types`, `storage ls|mv|rm|cp`, `seed + * buckets`) — single source of truth so every one of those commands reports + * the same text for the same condition. + * + * A DEFAULTED workdir keeps today's exact wording (pinned by + * `diff.e2e.test.ts`/`pull.e2e.test.ts`, both run with no `--workdir`): + * pointing at `supabase init`, since the ancestor walk-up already searched + * every directory between here and the filesystem root. + * + * An EXPLICIT `--workdir`/`SUPABASE_WORKDIR` never climbed past the named + * directory (see `legacy-workdir-search.ts`), so a bare `supabase init` hint + * would be misleading — it names the resolved path instead and never + * suggests `init`, since the fix is to point `--workdir`/`SUPABASE_WORKDIR` + * at the right directory, not to scaffold a new project there. + */ +export function legacyMissingProjectConfigMessage(input: { + readonly workdir: string; + readonly explicitWorkdir: boolean; +}): string { + if (!input.explicitWorkdir) { + return "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one."; + } + return `failed to read supabase/config.toml or supabase/config.json in ${legacySanitizeInlineName(input.workdir)}: file not found. --workdir/SUPABASE_WORKDIR is used exactly as given and no ancestor directory is searched, so it must name the directory that contains your supabase/ folder.`; +} + +/** + * As {@link legacyMissingProjectConfigMessage}, but appends a + * "Did you mean --workdir ?" hint when an ancestor directory + * actually holds a project. + * + * Only runs the extra ancestor search when `workdir` was set EXPLICITLY: a + * DEFAULTED workdir already exhaustively searched every ancestor up to the + * filesystem root while resolving `workdir` itself (`resolveWorkdir` in + * `legacy-cli-settings.layer.ts`), so there is never a "missed" ancestor left + * to suggest in that case. + * + * Purely a message enrichment: `findCliProjectPaths` itself never fails (a + * failed probe reads as "no config here", not an error — see its own doc + * comment), so the extra ancestor search can only ever change which sentence + * comes back, never surface a different error, mask the real failure, or + * crash the command. + */ +export const legacyMissingProjectConfigMessageEffect = Effect.fnUntraced(function* (cliSettings: { + readonly workdir: string; + readonly explicitWorkdir: boolean; +}) { + const base = legacyMissingProjectConfigMessage(cliSettings); + if (!cliSettings.explicitWorkdir) { + return base; + } + const ancestor = yield* findCliProjectPaths(cliSettings.workdir, { search: true }); + return ancestor === null + ? base + : `${base} Did you mean --workdir ${legacySanitizeInlineName(ancestor.projectRoot)}?`; +}); + +/** + * Raised by {@link legacyRequireExplicitWorkdirProject} when an explicit + * `--workdir`/`SUPABASE_WORKDIR` names a directory with no + * `supabase/config.toml`/`config.json` of its own. Callers map this into + * their own command-specific error type, matching the established pattern + * for `LegacyWorkdirValidationError`. + */ +export class LegacyWorkdirProjectMissingError extends Data.TaggedError( + "LegacyWorkdirProjectMissingError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * Fails when an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` holds no project — + * a no-op for a DEFAULTED workdir, which keeps its established tolerant + * fallback to embedded defaults unchanged (CLI-2285). + * + * Only valid for callers that do NOT pass `tomlOnly: true` to their own + * `loadCliConfig` call: `loadCliConfig` with `tomlOnly: true` can return + * `null` even when this probe succeeds — a `config.json`-only project, where + * `findCliProjectPaths` matches it but the TOML-only reader then finds no + * `config.toml` and returns `null` anyway. Both current callers (`config + * push`, `seed buckets`) are non-`tomlOnly`, so the probe result and the + * later `loaded === null` check agree exactly. + */ +export const legacyRequireExplicitWorkdirProject = Effect.fnUntraced(function* (cliSettings: { + readonly workdir: string; + readonly explicitWorkdir: boolean; +}) { + if (!cliSettings.explicitWorkdir) { + return; + } + // `explicitWorkdir` is guaranteed `true` past the guard above, so + // `legacyShouldSearchAncestors` would always evaluate to `false` here — + // spelled out directly rather than through that predicate. + const paths = yield* findCliProjectPaths(cliSettings.workdir, { search: false }); + if (paths === null) { + return yield* new LegacyWorkdirProjectMissingError({ + message: yield* legacyMissingProjectConfigMessageEffect(cliSettings), + }); + } +}); diff --git a/apps/cli/src/command-internal/legacy-workdir-project.unit.test.ts b/apps/cli/src/command-internal/legacy-workdir-project.unit.test.ts new file mode 100644 index 0000000000..3626d78521 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-workdir-project.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { legacyMissingProjectConfigMessage } from "./legacy-workdir-project.ts"; + +describe("legacyMissingProjectConfigMessage", () => { + it("points at `supabase init` for a defaulted workdir", () => { + // Pinned byte-for-byte: `diff.e2e.test.ts`/`pull.e2e.test.ts` assert this + // exact string when run with no `--workdir`. + expect( + legacyMissingProjectConfigMessage({ workdir: "/repo/sub", explicitWorkdir: false }), + ).toBe( + "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", + ); + }); + + it("names the resolved path and never suggests `supabase init` for an explicit workdir", () => { + const message = legacyMissingProjectConfigMessage({ + workdir: "/repo/sub", + explicitWorkdir: true, + }); + expect(message).toContain("/repo/sub"); + expect(message).toContain("--workdir/SUPABASE_WORKDIR"); + expect(message).not.toContain("supabase init"); + }); + + it("sanitizes control characters out of an explicit workdir before interpolating it", () => { + const message = legacyMissingProjectConfigMessage({ + workdir: "/repo/subevil", + explicitWorkdir: true, + }); + expect(message).not.toContain(""); + }); +}); diff --git a/apps/cli/src/command-internal/legacy-workdir-search.ts b/apps/cli/src/command-internal/legacy-workdir-search.ts new file mode 100644 index 0000000000..394ae42029 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-workdir-search.ts @@ -0,0 +1,37 @@ +/** + * Whether a config load should climb ancestor directories looking for + * `supabase/config.{toml,json}` beyond the resolved + * `LegacyCliSettings.workdir`. + * + * 1. An explicit workdir is authoritative — letting `loadCliConfig` climb + * again on top of it would let an unrelated ancestor project's config + * win (this is the CLI-2285 bug). + * 2. A defaulted (unset) workdir must still climb inside `loadCliConfig`, + * because the workdir's own default resolution (`resolveWorkdir` in + * `legacy-cli-settings.layer.ts`) only probes `supabase/config.toml` — a + * `config.json`-only project invoked from a subdirectory relies on this + * second, format-aware climb to be found. + * 3. A caller that already passes `tomlOnly: true` to `loadCliConfig` (e.g. + * `legacy-local-project-context.ts`, `gen.signing-keys-config.ts`, + * `shared/functions/serve.ts`'s `goConfigCompat` branch) doesn't need + * this helper — for those, the default-workdir climb and the second + * climb are checking the exact same thing, so they already correctly + * pass `search: false` unconditionally instead. + * 4. Passing this predicate is only half the contract. A JSON-capable load + * that also **tolerates** a `null` result must either hard-fail when + * `explicitWorkdir` is true (`config diff`/`push`/`pull`, `gen types`, + * `storage ls|mv|rm|cp`, `seed buckets`) or document why falling back is + * right for it. The three documented exceptions, all of which + * intentionally scaffold into or report on a bare directory: `functions + * new` (templates use embedded defaults — port/publishable key), + * `experimental workers new` (`workers new api --workdir ./bare-dir` + * must create the entry there), and `legacySeedBucketsRun`'s own load, + * which is only reached by the standalone `seed buckets` command — + * `start` and `db reset` pass `resolvedConfig` and never load config here + * at all. + */ +export function legacyShouldSearchAncestors(cliSettings: { + readonly explicitWorkdir: boolean; +}): boolean { + return !cliSettings.explicitWorkdir; +} diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts index 08708019fd..b46793eb2b 100644 --- a/apps/cli/src/commands/config/config.load.ts +++ b/apps/cli/src/commands/config/config.load.ts @@ -1,14 +1,16 @@ import { loadCliConfig } from "@supabase/config/internal"; import { Effect } from "effect"; -/** - * `cause.path`/`loaded.path` are anchored under `workdir`; render them - * relative so a message reads `supabase/config.json` like the rest of the - * `config` family, regardless of invocation cwd. - */ -export function legacyRelativeConfigPath(workdir: string, path: string): string { - return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; -} +import { + legacyMissingProjectConfigMessageEffect, + legacyRelativeConfigPath, +} from "../../command-internal/legacy-workdir-project.ts"; +import { legacyShouldSearchAncestors } from "../../command-internal/legacy-workdir-search.ts"; + +// Re-exported for existing `config` family importers — the pure helper +// itself now lives in `legacy-workdir-project.ts` since `gen types` needs it +// too (Hoist Before You Duplicate: used across ≥2 command families). +export { legacyRelativeConfigPath }; /** * Loads `supabase/config.{toml,json}` for the `config` command family @@ -17,33 +19,42 @@ export function legacyRelativeConfigPath(workdir: string, path: string): string * `supabase/config.json` before falling back to `supabase/config.toml` * (`findCliProjectPaths`), so hardcoding the `.toml` name would mislabel a * broken `config.json` — a duplicate `[remotes.*].project_id` keeps its own - * message, and a missing file points at `supabase init`. Every family member - * keeps its own tagged error class; `makeError` builds it from the shared - * message text, mirroring `legacyResolveConfigTarget`'s per-family error - * construction (`config.target.ts`). + * message, and a missing-file message is built by + * `legacyMissingProjectConfigMessageEffect` (CLI-2285): it points at + * `supabase init` only for a DEFAULTED workdir, and (via + * `legacyShouldSearchAncestors`) never climbs ancestors to find the config in + * the first place when `cliSettings.explicitWorkdir` is true — an explicit + * `--workdir`/`SUPABASE_WORKDIR` with no project of its own must fail rather + * than silently loading an unrelated ancestor project's config. Every family + * member keeps its own tagged error class; `makeError` builds it from the + * shared message text, mirroring `legacyResolveConfigTarget`'s per-family + * error construction (`config.target.ts`). */ export function legacyLoadLocalConfig( - workdir: string, + cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, projectRef: string | undefined, makeError: (message: string) => E, ) { - return loadCliConfig(workdir, { projectRef, goViperCompat: true }).pipe( + return loadCliConfig(cliSettings.workdir, { + projectRef, + goViperCompat: true, + search: legacyShouldSearchAncestors(cliSettings), + }).pipe( Effect.catchTags({ CliConfigParseError: (cause) => Effect.fail( makeError( - `failed to parse ${legacyRelativeConfigPath(workdir, cause.path)}: ${String(cause.cause)}`, + `failed to parse ${legacyRelativeConfigPath(cliSettings.workdir, cause.path)}: ${String(cause.cause)}`, ), ), DuplicateRemoteProjectIdError: (cause) => Effect.fail(makeError(cause.message)), }), Effect.flatMap((loaded) => loaded === null - ? Effect.fail( - makeError( - "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", - ), - ) + ? Effect.gen(function* () { + const message = yield* legacyMissingProjectConfigMessageEffect(cliSettings); + return yield* Effect.fail(makeError(message)); + }) : Effect.succeed(loaded), ), ); diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index ef034cbb62..eb189803a9 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -39,12 +39,13 @@ All Bearer-authenticated, all read-only. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | ## Exit Codes @@ -63,18 +64,19 @@ does. Because code `0`, a `--exit-code` run that exits `2` on drift suppresses that hook, same as any other non-zero exit. -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------- | -| `0` | success — including when differences are found, unless `--exit-code` is passed | -| `2` | `--exit-code` passed and at least one difference found | -| `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | -| `1` | missing or malformed `supabase/config.toml`/`config.json` | -| `1` | branch-name `--project-ref` with no linked parent project (`LegacyConfigDiffBranchNotLinkedError`) | -| `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`LegacyConfigDiffParentRefInvalidError`) | -| `1` | unknown branch (branch-name `--project-ref` 404, `LegacyConfigDiffBranchNotFoundError`) | -| `1` | resolved branch has no project ref yet — still provisioning (`LegacyConfigDiffBranchNotReadyError`) | -| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | -| `1` | remote config read failure (network, 401/403/404, or other unexpected status) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — including when differences are found, unless `--exit-code` is passed | +| `2` | `--exit-code` passed and at least one difference found | +| `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyConfigDiffWorkdirError`) — beats the config read and every network call | +| `1` | missing or malformed `supabase/config.toml`/`config.json` (`LegacyConfigDiffLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | +| `1` | branch-name `--project-ref` with no linked parent project (`LegacyConfigDiffBranchNotLinkedError`) | +| `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`LegacyConfigDiffParentRefInvalidError`) | +| `1` | unknown branch (branch-name `--project-ref` 404, `LegacyConfigDiffBranchNotFoundError`) | +| `1` | resolved branch has no project ref yet — still provisioning (`LegacyConfigDiffBranchNotReadyError`) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | remote config read failure (network, 401/403/404, or other unexpected status) | ## Output @@ -139,6 +141,7 @@ and emit nothing. ## Notes - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. + An explicit `--workdir`/`SUPABASE_WORKDIR` is never climbed past — see the `SUPABASE_WORKDIR` row above. - **Local operand per target (ADR 0018/0022):** when the resolved target ref matches a `[remotes.]` block's `project_id`, the local side is that branch's merged effective config; otherwise the base config. The echoed scope line always says which. diff --git a/apps/cli/src/commands/config/diff/diff.errors.ts b/apps/cli/src/commands/config/diff/diff.errors.ts index 608bfc3f28..324bde90f4 100644 --- a/apps/cli/src/commands/config/diff/diff.errors.ts +++ b/apps/cli/src/commands/config/diff/diff.errors.ts @@ -27,6 +27,19 @@ export class LegacyConfigDiffLoadConfigError extends Data.TaggedError( } } +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats the config load and every network call. + */ +export class LegacyConfigDiffWorkdirError extends Data.TaggedError("LegacyConfigDiffWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * The Go-compat global `-o/--output` flag was passed. `config diff` is a * net-new TS command with no Go parity contract, so machine output goes diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index 93c06d40a7..7c99e50da6 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -5,10 +5,11 @@ import { } from "@supabase/config/effect"; import { remoteNameForProjectRef } from "@supabase/config/internal"; import { operationDefinitions } from "@supabase/api/effect"; -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; @@ -45,6 +46,7 @@ import { LegacyConfigDiffParentRefInvalidError, LegacyConfigDiffReadNetworkError, LegacyConfigDiffReadStatusError, + LegacyConfigDiffWorkdirError, } from "./diff.errors.ts"; import type { LegacyConfigDiffFlags } from "./diff.command.ts"; @@ -74,6 +76,7 @@ export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( const cliSettings = yield* LegacyCliSettings; const processControl = yield* ProcessControl; const goOutputFlag = yield* LegacyOutputFlag; + const fs = yield* FileSystem.FileSystem; // An empty `--project-ref` value is absent, mirroring the resolver's own rule. const requested = Option.filter(flags.projectRef, (value) => value.length > 0); @@ -87,7 +90,7 @@ export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( // message shapes; only this family's own tagged error class is local. const loadLocalConfig = (projectRef: string | undefined) => legacyLoadLocalConfig( - cliSettings.workdir, + cliSettings, projectRef, (message) => new LegacyConfigDiffLoadConfigError({ message }), ); @@ -113,6 +116,15 @@ export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( }); } + // 1.5. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a + // directory before anything else runs — distinguishes "the directory + // doesn't exist" (`failed to change workdir: chdir …`) from "it exists + // but holds no `supabase/` project" (the step-2 load below), and runs + // before the config read and every network call. + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyConfigDiffWorkdirError({ message: error.message })), + ); + // 2. Load and validate the local config BEFORE any network call or // target resolution (never writes — this command is read-only by // contract): a missing file must point at `supabase init` rather than diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 7a8f154f1b..278f148c8d 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -95,6 +95,10 @@ interface SetupOpts { readonly projectId?: Option.Option; /** Overrides the process cwd (defaults to the temp workdir). */ readonly cwd?: string; + /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ + readonly workdir?: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; readonly analytics?: ReturnType; } @@ -135,7 +139,8 @@ function setup(opts: SetupOpts = {}) { out, api, cliSettings: mockLegacyCliSettings({ - workdir: tempRoot.current, + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, ...(opts.projectId !== undefined ? { projectId: opts.projectId } : opts.linked === false @@ -545,6 +550,100 @@ describe("legacy config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", + () => { + // CLI-2285 regression: an explicit --workdir is authoritative and must + // never let `loadCliConfig` climb past it — otherwise `config diff + // --workdir ./sub` from a project whose subdirectory has no + // supabase/ of its own would silently diff an unrelated PARENT + // project's config. The ancestor (tempRoot) genuinely has a valid + // config.toml and the subdirectory genuinely has none, so this + // exercises the real climb, not a tautology. + writeConfig('project_id = "test"\n'); + const sub = join(tempRoot.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api, telemetry } = setup({ workdir: sub, explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffLoadConfigError"); + expect(rendered).toContain("file not found"); + // An EXPLICIT workdir never gets the ancestor-search-exhausted + // `supabase init` hint — it names the resolved directory instead, and + // points at the flag/env var that must change. + expect(rendered).not.toContain("supabase init"); + expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); + expect(rendered).toContain(sub); + // The ancestor genuinely has a valid project, so the message also + // hints at it — `legacyMissingProjectConfigMessageEffect`'s "Did you + // mean" enrichment, only reachable because the search above is real. + expect(rendered).toContain(`Did you mean --workdir ${tempRoot.current}?`); + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not hint at an ancestor when explicit --workdir has no project anywhere above it", + () => { + // Negative counterpart of the regression above: no ancestor, all the + // way up, has a project of its own — so the "Did you mean" enrichment + // must never fire (or crash) when its best-effort probe finds nothing. + const { layer, api } = setup({ explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffLoadConfigError"); + expect(rendered).not.toContain("Did you mean"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any config load", + () => { + // Distinct from the "exists but holds no project" regression above: + // this path was never created, so `legacyValidateWorkdirIsDirectory` + // must fail first, before `loadCliConfig` is ever reached. + const missing = join(tempRoot.current, "does-not-exist"); + const { layer, api } = setup({ workdir: missing, explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffWorkdirError"); + expect(rendered).toContain("failed to change workdir: chdir"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "a defaulted workdir still resolves a config.json project root above a config-less subdirectory", + () => { + // Complements the regression above: a defaulted (unset) --workdir must + // keep climbing so a config.json-only project invoked from a + // subdirectory still resolves — proving the fix didn't break the + // legitimate default-climb case. + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify({ project_id: "test" })); + const sub = join(tempRoot.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api } = setup({ workdir: sub, explicitWorkdir: false }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(true); + expect(api.requests).not.toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("a malformed config aborts before any network call, even with a branch target", () => { // A broken TOML must not burn a branch-resolution round trip — the local // document is parsed and validated first. diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index b6620b4135..49f7fd0f10 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -61,15 +61,15 @@ locally via the file write above). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json` is read from and written to (`--workdir` flag takes priority) | no (defaults to the current directory) | -| `SUPABASE_YES` | answers the confirmation prompt "yes" (same effect as `--yes`); does **not** bypass the uncommitted-changes guard — only `--force` does | no | -| `env(VAR)` references | interpolated into `config.toml` values at load; a change whose LOCAL value resolved from `env()` is always skipped, never overwritten | no | -| `env(VAR)` references | a change whose REMOTE value is itself spelled `env(VAR)` is also always skipped (`remote_env_reference`), never written verbatim — the loader would resolve it against THIS machine's environment on the next load, which would otherwise let a remote value smuggle a local secret into `config diff`/`config push` | no | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json` is read from and written to (`--workdir` flag takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails (with no `supabase init` hint — see the exit-code table) instead of loading a parent project's config | +| `SUPABASE_YES` | answers the confirmation prompt "yes" (same effect as `--yes`); does **not** bypass the uncommitted-changes guard — only `--force` does | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change whose LOCAL value resolved from `env()` is always skipped, never overwritten | no | +| `env(VAR)` references | a change whose REMOTE value is itself spelled `env(VAR)` is also always skipped (`remote_env_reference`), never written verbatim — the loader would resolve it against THIS machine's environment on the next load, which would otherwise let a remote value smuggle a local secret into `config diff`/`config push` | no | ## Exit Codes @@ -81,7 +81,8 @@ exit `0`: neither is a failure. | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success — including "no differences found", "differences found but none writable", and a declined confirmation prompt | | `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | -| `1` | missing or malformed `supabase/config.toml`/`config.json` (`LegacyConfigPullLoadConfigError`) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyConfigPullWorkdirError`) — beats the base config load and every network call | +| `1` | missing or malformed `supabase/config.toml`/`config.json` (`LegacyConfigPullLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | | `1` | branch-name `--project-ref` with no linked parent project (`LegacyConfigPullBranchNotLinkedError`) | | `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`LegacyConfigPullParentRefInvalidError`) | | `1` | unknown branch (branch-name `--project-ref` 404, `LegacyConfigPullBranchNotFoundError`) | diff --git a/apps/cli/src/commands/config/pull/pull.errors.ts b/apps/cli/src/commands/config/pull/pull.errors.ts index 93e7b32631..fd44f48c4d 100644 --- a/apps/cli/src/commands/config/pull/pull.errors.ts +++ b/apps/cli/src/commands/config/pull/pull.errors.ts @@ -27,6 +27,20 @@ export class LegacyConfigPullLoadConfigError extends Data.TaggedError( } } +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats the base config load and every network + * call. + */ +export class LegacyConfigPullWorkdirError extends Data.TaggedError("LegacyConfigPullWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * The Go-compat global `-o/--output` flag was passed. `config pull` is a * net-new TS command with no Go parity contract, so machine output goes diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index fee3955a01..1ba2ac1332 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -27,6 +27,7 @@ import { sanitizeLegacyErrorBody, } from "../../../command-internal/legacy-http-errors.ts"; import { LEGACY_BRANCH_UUID_PATTERN } from "../../../command-internal/legacy-ref-patterns.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYes, LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; @@ -95,6 +96,7 @@ import { LegacyConfigPullUncommittedChangesError, LegacyConfigPullUnsupportedLayoutError, LegacyConfigPullValidationFailedError, + LegacyConfigPullWorkdirError, LegacyConfigPullWriteError, } from "./pull.errors.ts"; import type { LegacyConfigPullFlags } from "./pull.command.ts"; @@ -623,17 +625,23 @@ const legacyValidateConfigPullPlan = Effect.fnUntraced(function* (input: { * factory rather than a shared closure so both `legacyOpenConfigPullSource` * (steps 2-3) and `legacyRunConfigPull` (step 6's conditional reload) get * their own, independently testable copy without threading `cliSettings` - * through {@link LegacyConfigPullInput}. `legacyLoadLocalConfig` - * (`../config.load.ts`, shared with `config diff`/`config push`) owns the - * parse/duplicate-remote/missing-file message shapes; only this family's own - * tagged error class is local. */ -function makeConfigLoader(cliSettings: { readonly workdir: string }) { + * through {@link LegacyConfigPullInput}. Narrowed to `workdir` + + * `explicitWorkdir` (rather than the full `LegacyCliSettings` shape) since + * that's all `legacyLoadLocalConfig` (`../config.load.ts`, shared with + * `config diff`/`config push`) needs — it owns the parse/duplicate-remote/ + * missing-file message shapes and the ancestor-search decision + * (`legacyShouldSearchAncestors`); only this family's own tagged error class + * is local. */ +function makeConfigLoader(cliSettings: { + readonly workdir: string; + readonly explicitWorkdir: boolean; +}) { const relativeConfigPath = (path: string): string => legacyRelativeConfigPath(cliSettings.workdir, path); const loadLocalConfig = (projectRef: string | undefined) => legacyLoadLocalConfig( - cliSettings.workdir, + cliSettings, projectRef, (message) => new LegacyConfigPullLoadConfigError({ message }), ); @@ -1043,6 +1051,8 @@ export const legacyConfigPull = Effect.fn("legacy.config.pull")(function* ( ) { const goOutputFlag = yield* LegacyOutputFlag; const yes = yield* legacyResolveYes; + const cliSettings = yield* LegacyCliSettings; + const fs = yield* FileSystem.FileSystem; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; @@ -1069,6 +1079,14 @@ export const legacyConfigPull = Effect.fn("legacy.config.pull")(function* ( }); } + // 1.5. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a + // directory before the base config source is opened — distinguishes + // "the directory doesn't exist" from "it exists but holds no + // `supabase/` project" (the step 2-3 load below). + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyConfigPullWorkdirError({ message: error.message })), + ); + // 2-3. Open the base config source (load with NO `[remotes.*]` overlay, // paired with its on-disk text) BEFORE any network call or target // resolution — a missing file must point at `supabase init` rather than diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 06e50c837e..8b90aa7af8 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { chmodSync, + existsSync, mkdirSync, readdirSync, readFileSync, @@ -387,6 +388,10 @@ interface SetupOpts { /** Runs as a side effect of every `promptConfirm` call, BEFORE it resolves * — simulates a concurrent edit landing while the prompt is on screen. */ readonly confirmSideEffect?: () => void; + /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ + readonly workdir?: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; } function setup(opts: SetupOpts = {}) { @@ -448,7 +453,8 @@ function setup(opts: SetupOpts = {}) { out: { layer: outputLayer }, api, cliSettings: mockLegacyCliSettings({ - workdir: tempRoot.current, + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, ...(opts.projectId !== undefined ? { projectId: opts.projectId } : opts.linked === false @@ -707,7 +713,12 @@ describe("legacy config pull integration", () => { return Effect.gen(function* () { const exit = yield* legacyConfigPull(noFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("LegacyConfigPullLoadConfigError"); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPullLoadConfigError"); + // A DEFAULTED workdir with no project keeps the established + // `supabase init` suggestion — only an EXPLICIT --workdir/SUPABASE_WORKDIR + // gets the resolved-path wording (see the CLI-2285 regression below). + expect(rendered).toContain("supabase init"); // The load runs before any network call or target resolution, so the // linked-project cache never fires — no ref ever resolved. expect(api.requests).toHaveLength(0); @@ -716,6 +727,63 @@ describe("legacy config pull integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", + () => { + // CLI-2285 regression: an explicit --workdir is authoritative and must + // never let `loadCliConfig` climb past it — a `config pull --workdir + // ./sub` from a project whose subdirectory has no supabase/ of its + // own must not silently overwrite an unrelated PARENT project's + // config. The ancestor (tempRoot) genuinely has a valid config.toml + // (captured below to prove it is never touched) and the subdirectory + // genuinely has none. + const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; + const sub = join(tempRoot.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer } = setup({ toml: before, yes: true, workdir: sub, explicitWorkdir: true }); + const path = configPath(); + const beforeStat = { mtimeMs: statSync(path).mtimeMs, contents: readFileSync(path, "utf8") }; + return Effect.gen(function* () { + const exit = yield* legacyConfigPull(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPullLoadConfigError"); + expect(rendered).toContain("file not found"); + // An EXPLICIT workdir never gets the ancestor-search-exhausted + // `supabase init` hint — it names the resolved directory instead, and + // points at the flag/env var that must change. + expect(rendered).not.toContain("supabase init"); + expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); + expect(rendered).toContain(sub); + // Nothing was written to disk anywhere — the ancestor config file + // stays byte-identical and untouched. + expect(statSync(path).mtimeMs).toBe(beforeStat.mtimeMs); + expect(readFileSync(path, "utf8")).toBe(beforeStat.contents); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any config load", + () => { + // Distinct from the "exists but holds no project" regression above: + // this path was never created, so `legacyValidateWorkdirIsDirectory` + // must fail first, and nothing is ever written to disk. + const missing = join(tempRoot.current, "does-not-exist"); + const { layer, api } = setup({ workdir: missing, explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPull(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPullWorkdirError"); + expect(rendered).toContain("failed to change workdir: chdir"); + expect(api.requests).toHaveLength(0); + // Nothing was written to disk — the missing directory stays missing. + expect(existsSync(missing)).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + // ------------------------------------------------------------------------- // Scope resolution / --remote-label (CLI-2064 §1.1). // ------------------------------------------------------------------------- diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index cd39b4a65a..8abf97dc27 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -14,14 +14,14 @@ notes below). A property your file doesn't declare is never written. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME `loadCliConfig` call so a matching `[remotes.]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | -| Auth email template HTML (`content_path`) | HTML | always (CLI-2314 — no longer gated on `auth.enabled`, which controls only the local GoTrue Docker service, not this write); paths resolved per the rules below, CONFINED to the project root (CLI-2320) — a relative `..` escape or an absolute path outside the root aborts before the file is read | -| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | -| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME `loadCliConfig` call so a matching `[remotes.]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1). An explicit `--workdir`/`SUPABASE_WORKDIR` is used exactly as given, with no ancestor search, and (CLI-2285) is probed for a project's presence BEFORE target resolution, so a typo'd `--workdir` fails without burning a branch-name/UUID lookup's network round trip | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | +| Auth email template HTML (`content_path`) | HTML | always (CLI-2314 — no longer gated on `auth.enabled`, which controls only the local GoTrue Docker service, not this write); paths resolved per the rules below, CONFINED to the project root (CLI-2320) — a relative `..` escape or an absolute path outside the root aborts before the file is read | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -119,31 +119,34 @@ cannot be resolved from either the read or the file — reported in their own ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load | no | -| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | +| Variable | Purpose | Required? | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | +| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | -| `1` | user declined the branch confirmation gate (cancellation, `LegacyConfigPushCancelledError`) — see Output below | -| `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | -| `1` | malformed `config.toml` | -| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | -| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file, or a path that resolves outside the project root) | -| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | -| `1` | list-addons failure (network or non-200) | -| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | -| `1` | the API's effective project configuration fails to parse | -| `1` | the effective-project-config response carried no block at all (`LegacyConfigPushConfigEmptyError`) — nothing was pushed | -| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyConfigPushWorkdirError`) — beats target resolution, the config load, and every network call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`LegacyConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | +| `1` | user declined the branch confirmation gate (cancellation, `LegacyConfigPushCancelledError`) — see Output below | +| `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | +| `1` | malformed or missing `config.toml`/`config.json` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | +| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file, or a path that resolves outside the project root) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | list-addons failure (network or non-200) | +| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | +| `1` | the API's effective project configuration fails to parse | +| `1` | the effective-project-config response carried no block at all (`LegacyConfigPushConfigEmptyError`) — nothing was pushed | +| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | ## Output diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 4f8da35fcf..6e1805d1fb 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -52,6 +52,20 @@ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( } } +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats target resolution, the config load, and + * every network call. + */ +export class LegacyConfigPushWorkdirError extends Data.TaggedError( + "LegacyConfigPushWorkdirError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + // --- branch/UUID resolution (CLI-2289) -------------------------------------- // // `--project-ref` accepts a project ref, or the name (or UUID) of one of its diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 7e02a539b9..03049dea9c 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -24,6 +24,9 @@ import { mapLegacyHttpError, sanitizeLegacyErrorBody, } from "../../../command-internal/legacy-http-errors.ts"; +import { legacyRequireExplicitWorkdirProject } from "../../../command-internal/legacy-workdir-project.ts"; +import { legacyShouldSearchAncestors } from "../../../command-internal/legacy-workdir-search.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { legacyPromptYesNo } from "../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyCollectDotenvPrivateKeys } from "../../../command-internal/legacy-vault-decrypt.ts"; import { legacyConfigApiScope, legacyConfigScopeLine } from "../config.format.ts"; @@ -75,6 +78,7 @@ import { LegacyConfigPushSslEnforcementUpdateStatusError, LegacyConfigPushStorageUpdateNetworkError, LegacyConfigPushStorageUpdateStatusError, + LegacyConfigPushWorkdirError, } from "./push.errors.ts"; import { legacyConfigPushBranchPromptLabel, @@ -161,33 +165,6 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const telemetryState = yield* LegacyTelemetryState; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before - // the confirmation prompt reads the yes flag, so a `SUPABASE_YES` set only - // in `supabase/.env` auto-confirms. Resolve against the project env, not - // just the flag + shell env. Load it from the resolved project root - // (walking up, same as `loadCliConfig` below and the workdir change - // before config load), so a push from a subdirectory still reads the - // project root's `supabase/.env`. - // Resolved against `cliSettings.workdir` — the same root the project-ref - // resolver and the linked-project cache use — so `--workdir ../other` - // pushes `../other`'s config.toml, never the invoking directory's file to - // another root's linked project. - const projectRoot = (yield* findCliProjectRoot(cliSettings.workdir)) ?? cliSettings.workdir; - const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); - const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - // dotenvx private keys for decrypting `encrypted:` secrets, from the shell - // + project env — same source/precedence as `legacy-db-config.toml-read.ts` - // (`process.env` wins over `supabase/.env`). - const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); - // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that - // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. - // when this wider env source resolves `VAR` but `@supabase/config`'s - // narrower one (`supabase/.env`/`.env.local` only) didn't. Practically - // unreachable in the same narrow way the CLI-1489 comment below already documents for - // non-secret fields; kept for parity with the shared function's other caller - // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). - const secretEnvLookup = (name: string): string | undefined => - process.env[name] ?? projectEnv[name]; // `--project-ref` accepts a project ref, or the name (or UUID) of a branch // of the linked project — `link`'s/`config diff`'s settled vocabulary @@ -203,6 +180,69 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( let resolvedRef: string | undefined; yield* Effect.gen(function* () { + // 0. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a + // directory before anything else touches it. The project-root probe, + // project-env load, and private-key collection immediately below used to + // run BEFORE this check, in the outer function body — harmless for a + // missing directory (`legacyLoadProjectEnv` tolerates `NotFound`), but not + // for a `--workdir` that names a regular FILE: `legacyLoadProjectEnv` + // does not tolerate ENOTDIR, so it surfaced a confusing + // "failed to read environment file: ..." error instead of this one, and + // it did so OUTSIDE the `Effect.ensuring(telemetryState.flush)` wrapper + // below. Moved here so both failure shapes are caught by the same check, + // and telemetry flushes for either one. + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyConfigPushWorkdirError({ message: error.message })), + ); + + // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before + // the confirmation prompt reads the yes flag, so a `SUPABASE_YES` set only + // in `supabase/.env` auto-confirms. Resolve against the project env, not + // just the flag + shell env. Load it from the resolved project root + // (climbing only when `cliSettings.workdir` was defaulted, same as + // `loadCliConfig` below — an explicit `--workdir`/`SUPABASE_WORKDIR` is + // authoritative and never climbs, see `legacyShouldSearchAncestors`), so a + // push from a subdirectory of a defaulted workdir still reads the project + // root's `supabase/.env`. + // Resolved against `cliSettings.workdir` — the same root the project-ref + // resolver and the linked-project cache use — so `--workdir ../other` + // pushes `../other`'s config.toml, never the invoking directory's file to + // another root's linked project. + const projectRoot = + (yield* findCliProjectRoot(cliSettings.workdir, { + search: legacyShouldSearchAncestors(cliSettings), + })) ?? cliSettings.workdir; + const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // dotenvx private keys for decrypting `encrypted:` secrets, from the shell + // + project env — same source/precedence as `legacy-db-config.toml-read.ts` + // (`process.env` wins over `supabase/.env`). + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that + // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. + // when this wider env source resolves `VAR` but `@supabase/config`'s + // narrower one (`supabase/.env`/`.env.local` only) didn't. Practically + // unreachable in the same narrow way the CLI-1489 comment below already documents for + // non-secret fields; kept for parity with the shared function's other caller + // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). + const secretEnvLookup = (name: string): string | undefined => + process.env[name] ?? projectEnv[name]; + + // 0.5. An explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project + // fails HERE, before target resolution burns a branch-name/UUID lookup's + // network round trip — a pure `fs.exists` probe with no schema decode, + // so it does not touch the "only ONE decode may ever run" invariant step + // 2 below relies on. A DEFAULTED workdir is untouched (today, `config + // push` in a config-less directory with no linked project fails with the + // not-linked error from step 1, not a config error) — deliberately kept, + // since making this check unconditional would be an established-behavior + // change outside this fix's scope. Message is identical to the step-2 + // `loaded === null` branch below (same builder), so the user-visible + // failure text is unchanged, only earlier in time. + yield* legacyRequireExplicitWorkdirProject(cliSettings).pipe( + Effect.mapError((error) => new LegacyConfigPushLoadConfigError({ message: error.message })), + ); + // 1. Resolve the push target. `--project-ref` accepts a project ref, or // the name (or UUID) of a branch of the linked project (CLI-2167/CLI-2289) — // `legacyResolveConfigTarget` (`../config.target.ts`, Hoist Before You @@ -241,9 +281,12 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // `loadCliConfig` raises `CliConfigParseError` on `env(...)` refs over // numeric/bool fields; `legacyLoadLocalConfig` catches it (and a // duplicate-remote/missing-file failure) and converts it to this - // family's own tagged error via the shared message shapes. + // family's own tagged error via the shared message shapes — including + // the ancestor-search decision (`legacyShouldSearchAncestors`), so an + // explicit `--workdir`/`SUPABASE_WORKDIR` with no project here never + // silently falls back to an ancestor project's config (CLI-2285). const loaded = yield* legacyLoadLocalConfig( - cliSettings.workdir, + cliSettings, ref, (message) => new LegacyConfigPushLoadConfigError({ message }), ); diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index e74e210c31..927ad777e9 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -239,6 +239,8 @@ function setup(opts: { readonly runtimeCwd?: string; /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ readonly workdir?: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; /** Analytics mock for tests asserting on captured telemetry events. */ readonly analytics?: ReturnType; // CLI-2168/CLI-2289 — live target-detection probe and branch-name/UUID @@ -362,6 +364,7 @@ function setup(opts: { api, cliSettings: mockLegacyCliSettings({ workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), }), runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), @@ -689,6 +692,130 @@ max_rows = 1000 ); }); + it.live( + "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", + () => { + // CLI-2285 regression: an explicit --workdir is authoritative and must + // never let `loadCliConfig`/`findCliProjectRoot` climb past it — a + // `config push --workdir ./sub` from a project whose subdirectory has + // no supabase/ of its own must not silently push over an unrelated + // PARENT project's config. The ancestor (tempRoot) genuinely has a + // valid config.toml and the subdirectory genuinely has none. + const sub = join(tempRoot.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api, telemetry } = setup({ + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + yes: true, + workdir: sub, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushLoadConfigError"); + expect(rendered).toContain("file not found"); + // An EXPLICIT workdir never gets the ancestor-search-exhausted + // `supabase init` hint — it names the resolved directory instead, and + // points at the flag/env var that must change. + expect(rendered).not.toContain("supabase init"); + expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); + expect(rendered).toContain(sub); + // A write command failing to load its OWN config must never reach + // any of the config-update endpoints it would otherwise PATCH/PUT. + expect(api.requests.some((r) => r.method === "PATCH" || r.method === "PUT")).toBe(false); + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a defaulted workdir with no project still points at supabase init", () => { + // Complements the regression above: the message text for a DEFAULTED + // workdir must keep pointing at `supabase init` — only an EXPLICIT + // `--workdir`/`SUPABASE_WORKDIR` gets the resolved-path wording. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + Effect.succeed(legacyJsonResponse(request, 200, { available_addons: [] })), + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + mockStdin(true), + Layer.succeed(LegacyYesFlag, true), + ); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushLoadConfigError"); + expect(rendered).toContain("supabase init"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before target resolution", + () => { + // Distinct from the "exists but holds no project" regression above: + // this path was never created, so `legacyValidateWorkdirIsDirectory` + // must fail first, before target resolution or the config load. + const missing = join(tempRoot.current, "does-not-exist"); + const { layer, api } = setup({ + toml: `project_id = "test"\n`, + yes: true, + workdir: missing, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushWorkdirError"); + expect(rendered).toContain("failed to change workdir: chdir"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "an explicit --workdir naming a regular file fails with the workdir error, not a confusing env-file error", + () => { + // Sibling of the "does not exist" regression above: this path DOES + // exist, but as a plain file rather than a directory. Before this fix, + // the prologue reads (project-root probe, `supabase/.env` load, + // dotenvx private-key collection) ran BEFORE `legacyValidateWorkdirIsDirectory`, + // in the outer function body — outside the `Effect.ensuring(telemetryState.flush)` + // wrapper below. `legacyLoadProjectEnv` does not tolerate ENOTDIR, so a + // `--workdir` naming a file surfaced a confusing "failed to read + // environment file: ..." error instead of `LegacyConfigPushWorkdirError`, + // and telemetry never flushed for it. Both must be fixed now. + const notADirectory = join(tempRoot.current, "not-a-directory"); + writeFileSync(notADirectory, ""); + const { layer, api, telemetry } = setup({ + toml: `project_id = "test"\n`, + yes: true, + workdir: notADirectory, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushWorkdirError"); + expect(rendered).toContain("failed to change workdir: chdir"); + expect(rendered).toContain("not a directory"); + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("emits a structured summary in json mode with every payload field", () => { const { layer, out } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, @@ -1184,6 +1311,8 @@ function setupService(opts: { readonly runtimeCwd?: string; /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ readonly workdir?: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ readonly stdinIsTty?: boolean; /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ @@ -1211,7 +1340,10 @@ function setupService(opts: { buildLegacyTestRuntime({ out, api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer(opts.addons) }, - cliSettings: mockLegacyCliSettings({ workdir: opts.workdir ?? tempRoot.current }), + cliSettings: mockLegacyCliSettings({ + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, + }), runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, diff --git a/apps/cli/src/commands/experimental/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/workers/delete/SIDE_EFFECTS.md index 4b425f2fdd..f3744b2649 100644 --- a/apps/cli/src/commands/experimental/workers/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/workers/delete/SIDE_EFFECTS.md @@ -7,15 +7,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the source directory it kept. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | -| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | -| `/` | directory | canonicalised and stat'd, to decide whether the kept-source line is stated at all | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the source directory it kept. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the reported source directory resolves against that SAME ancestor root | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms, with the same explicit-vs-default workdir rule | +| `/` | directory | canonicalised and stat'd, to decide whether the kept-source line is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -57,14 +57,14 @@ rather than deleting unasked. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | -| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/workers/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/workers/list/SIDE_EFFECTS.md index b7ae13668c..712bcb3cdb 100644 --- a/apps/cli/src/commands/experimental/workers/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/workers/list/SIDE_EFFECTS.md @@ -7,15 +7,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; the `[workers.*]` entries | -| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries | -| `/supabase/workers/` | directory | always — enumerated and each child stat'd, so a directory with no `[workers.]` entry still appears in the inventory. Absent reads as no workers; any other read failure fails the command | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; the `[workers.*]` entries. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and `supabase/workers/` below resolves against that SAME ancestor root, never the invoking directory's own | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries, with the same explicit-vs-default workdir rule | +| `/supabase/workers/` | directory | always — enumerated and each child stat'd, so a directory with no `[workers.]` entry still appears in the inventory. Absent reads as no workers; any other read failure fails the command | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -40,13 +40,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts b/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts index b9300c875b..f9f1a161fb 100644 --- a/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts @@ -506,4 +506,64 @@ describe("legacy workers list", () => { expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + + // CLI-2285: `legacyLoadWorkersProject`'s JSON-capable read must thread the + // ancestor-search predicate — the workdir's own default resolution only + // probes config.toml, so a config.json-only project invoked from a + // subdirectory relied on this second climb to be found at all. + it.live( + "discovers a config.json-only project's [workers.*] entry from a subdirectory when --workdir is defaulted", + () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb" } }, + }), + }); + const sub = join(created.dir, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const cleanup = () => rmSync(created.dir, { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ + workdir: sub, + explicitWorkdir: false, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const row = out.stdoutText.split("\n").find((line) => line.includes("api")); + expect(row).toBeDefined(); + expect(row).toContain("not deployed"); + expect(row).toContain("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); + }, + ); + + it.live( + "does not discover the same config.json-only entry when --workdir is explicit (preserves bare-directory scaffolding semantics)", + () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb" } }, + }), + }); + const sub = join(created.dir, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const cleanup = () => rmSync(created.dir, { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ + workdir: sub, + explicitWorkdir: true, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).not.toContain("api"); + expect(out.stdoutText).toContain("No workers found."); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); + }, + ); }); diff --git a/apps/cli/src/commands/experimental/workers/new/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/workers/new/SIDE_EFFECTS.md index 433cc51025..2f03f21c8f 100644 --- a/apps/cli/src/commands/experimental/workers/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/workers/new/SIDE_EFFECTS.md @@ -76,15 +76,16 @@ root. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | invalid worker name — the name must be a DNS label | -| `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force | -| `1` | bad `--source`: outside the project, or a path the CLI owns | -| `1` | destination exists and is not empty | -| `1` | the worker is already recorded in `config.toml`, in any form | -| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyWorkersNewWorkdirError`) — beats every prompt and filesystem write | +| `1` | invalid worker name — the name must be a DNS label | +| `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force | +| `1` | bad `--source`: outside the project, or a path the CLI owns | +| `1` | destination exists and is not empty | +| `1` | the worker is already recorded in `config.toml`, in any form | +| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table | ## Environment Variables @@ -110,3 +111,8 @@ Nothing is emitted for a failure the parser catches, such as a the instrumentation either — and `telemetry.json` is not written. A missing name is _not_ one of those: the argument is optional, so a bare `workers new` reaches the handler, which asks for the name or fails for want of anywhere to ask. + +## Notes + +- A non-existent `--workdir`/`SUPABASE_WORKDIR` now fails before any directory or file is created (CLI-2285) — previously a typo'd `--workdir` could scaffold a fresh `supabase/workers/…` tree (plus a new `config.toml`) at the wrong path. +- The `Created new Worker at ` line (and the equivalent machine-format `source` field) shows the absolute path when `--workdir`/`SUPABASE_WORKDIR` was set explicitly, since the scaffolded directory is then not necessarily relative to the terminal the command was run from. diff --git a/apps/cli/src/commands/experimental/workers/new/new.errors.ts b/apps/cli/src/commands/experimental/workers/new/new.errors.ts new file mode 100644 index 0000000000..3c16c3068d --- /dev/null +++ b/apps/cli/src/commands/experimental/workers/new/new.errors.ts @@ -0,0 +1,22 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the user + * explicitly set it — beats every prompt and filesystem write, so a typo'd + * `--workdir` can never scaffold a fresh `supabase/workers/…` tree (plus a new + * `config.toml`) at the wrong path. Mirrors `LegacyFunctionsNewWorkdirError`, + * `new`'s sibling in `supabase functions`. + */ +export class LegacyWorkersNewWorkdirError extends Data.TaggedError("LegacyWorkersNewWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/experimental/workers/new/new.handler.ts b/apps/cli/src/commands/experimental/workers/new/new.handler.ts index 981ab260d8..762eee4e6c 100644 --- a/apps/cli/src/commands/experimental/workers/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/workers/new/new.handler.ts @@ -3,6 +3,8 @@ import { Effect, FileSystem, Option } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { emitSuccessTrailer } from "../../../../shared/cli/success-trailer.ts"; import { legacyAqua, legacyBold } from "../../../../command-internal/legacy-colors.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../../command-internal/legacy-workdir-validation.ts"; +import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { legacyEmitWorkersMachineOutput, @@ -46,11 +48,13 @@ import { WorkerDirectoryExistsError, } from "../../../../shared/workers/workers.errors.ts"; import { + legacyLoadWorkersProject, legacyLoadWorkersProjectForEntryWrite, legacyValidateWorkerName, type LegacyWorkersProject, } from "../workers.shared.ts"; import type { LegacyWorkersNewFlags } from "./new.command.ts"; +import { LegacyWorkersNewWorkdirError } from "./new.errors.ts"; /** * `supabase experimental workers new [name]` — scaffold `supabase/workers//` from the @@ -256,9 +260,14 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun const output = yield* Output; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; + const cliSettings = yield* LegacyCliSettings; // The telemetry state file is written on every invocation, success or failure. yield* Effect.gen(function* () { + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyWorkersNewWorkdirError({ message: error.message })), + ); + const project = yield* legacyLoadWorkersProjectForEntryWrite(); // Decided once, before the first prompt rather than beside the last, since @@ -283,6 +292,34 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun ); } + // A DEFAULTED workdir's reader (`workers list`/`push`/`status`, used + // via `legacyLoadWorkersProject`) can discover a config.json-only + // ancestor project by climbing (CLI-2285); this command's own writer + // above is TOML-only and never climbs, so the two can disagree about + // which project is "the" project. When they do, and that ancestor + // already configures this name, writing a same-named worker here would + // silently create a second, disagreeing `[workers.]` under a + // different root instead of the collision already refused above for + // this command's OWN root. An explicit `--workdir`/`SUPABASE_WORKDIR` + // never has this gap — both views are pinned to the same root then — so + // this only runs for a defaulted workdir, and only costs an extra read + // when it is. + if (!cliSettings.explicitWorkdir) { + const discovered = yield* legacyLoadWorkersProject().pipe(Effect.option); + if ( + Option.isSome(discovered) && + discovered.value.projectRoot !== project.projectRoot && + discovered.value.section.workers[name] !== undefined + ) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${name}" is already configured in ${discovered.value.configPath}.`, + suggestion: `Run this command from ${discovered.value.projectRoot} to manage it there, or pick a different worker name.`, + }), + ); + } + } + // Resolved before anything is written, so cancelling any prompt leaves // nothing behind — the name included. With nowhere to ask, the defaults // stand; only the name has nothing to fall back to. @@ -321,7 +358,12 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun // is to create a worker has no business removing whatever happens to share // its name — so it says what is in the way and leaves the choice to the user. if (!(yield* destinationIsFree(destination))) { - const shown = displayPath(project.projectRoot, destination); + // Absolute when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — same + // rule as the success message below — since a project-root-relative + // path would be misleading once `--workdir` differs from cwd. + const shown = cliSettings.explicitWorkdir + ? destination + : displayPath(project.projectRoot, destination); return yield* Effect.fail( new WorkerDirectoryExistsError({ detail: `${shown} already exists and is not empty.`, @@ -364,7 +406,15 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun yield* commitWorkerEntry(configWrite); - const sourceDisplay = displayPath(project.projectRoot, destination); + // Relative to the project root when the workdir was defaulted — the + // common case, where it also reads as relative to the terminal the + // command was run from. An explicit `--workdir` breaks that: the project + // root can be nowhere near the actual cwd, so a relative path here would + // point somewhere the user never typed. The absolute path is unambiguous + // either way. + const sourceDisplay = cliSettings.explicitWorkdir + ? destination + : displayPath(project.projectRoot, destination); const payload = { worker_name: name, diff --git a/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts b/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts index 0fd0cc5195..115dd6750c 100644 --- a/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Exit, Option } from "effect"; import { makeWorkersProject, setupLegacyWorkers, @@ -355,6 +355,62 @@ describe("legacy workers new", () => { }, ); + // CLI-2285 review follow-up: a DEFAULTED workdir's reader (`workers + // list`/`push`/`status`) can climb to discover a config.json-only ancestor + // project, but this command's own TOML-only writer never climbs — without + // an extra check, `new` would silently write a same-named duplicate at the + // subdirectory instead of refusing it the way it already refuses a + // duplicate at its own root. + it.live( + "refuses a name the reader would discover in a config.json-only ancestor project (defaulted workdir)", + () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb" } }, + }), + }); + const sub = join(created.dir, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const cleanup = () => rmSync(created.dir, { recursive: true, force: true }); + const { layer } = setupLegacyWorkers({ workdir: sub, explicitWorkdir: false }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: Option.some("api"), runtime: Option.some("deno") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // Nothing was scaffolded at the subdirectory either. + expect(existsSync(join(sub, "supabase"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); + }, + ); + + it.live( + "does not refuse the same name when --workdir is explicit (writer and reader agree on the same root)", + () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb" } }, + }), + }); + const sub = join(created.dir, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const cleanup = () => rmSync(created.dir, { recursive: true, force: true }); + const { layer } = setupLegacyWorkers({ workdir: sub, explicitWorkdir: true }); + + return Effect.gen(function* () { + // An explicit workdir never climbs for either the reader or the + // writer, so the ancestor's config.json is invisible to both — this + // is the established bare-directory scaffold, unaffected by the fix. + yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("deno") })); + expect(existsSync(join(sub, "supabase", "config.toml"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); + }, + ); + it.live("records a --source worker relative to the project root", () => { const repo = project(); const { layer } = setupLegacyWorkers({ workdir: repo.dir }); @@ -399,15 +455,19 @@ describe("legacy workers new", () => { }); it.live("scaffolds in a directory that has no Supabase project yet", () => { const created = makeWorkersProject(); - const { layer } = setupLegacyWorkers({ workdir: created.dir }); + const { layer, out } = setupLegacyWorkers({ workdir: created.dir, explicitWorkdir: true }); return Effect.gen(function* () { yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); - expect(existsSync(join(created.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + const workerDir = join(created.dir, "supabase", "workers", "api"); + expect(existsSync(join(workerDir, "index.mjs"))).toBe(true); expect(readFileSync(join(created.dir, "supabase", "config.toml"), "utf8")).toBe( `[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); + // An EXPLICIT --workdir has no cwd-relative reading, so the success + // message names the absolute path rather than a project-root-relative one. + expect(out.stdoutText).toContain(`Created new Worker at ${workerDir}`); }).pipe( Effect.provide(layer), Effect.ensuring(Effect.sync(() => rmSync(created.dir, { recursive: true, force: true }))), @@ -743,4 +803,33 @@ describe("legacy workers new", () => { expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + + // CLI-2285 regression: before this fix, a typo'd/nonexistent --workdir + // reached `fs.makeDirectory(destination, { recursive: true })` below with no + // prior existence check, silently scaffolding a fresh + // supabase/workers// tree (plus a new config.toml) at the wrong path. + // `legacyValidateWorkdirIsDirectory` must now fail first, before anything on + // disk changes. + it.live( + "fails without scaffolding anything when --workdir names a directory that does not exist at all", + () => { + const repo = project(); + const badWorkdir = join(repo.dir, "does-not-exist"); + const { layer } = setupLegacyWorkers({ workdir: badWorkdir, explicitWorkdir: true }); + + return Effect.gen(function* () { + const exit = yield* legacyWorkersNew(flags()).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyWorkersNewWorkdirError"); + expect(rendered).toContain("failed to change workdir: chdir"); + + // The critical safety assertion: nothing was scaffolded at the bad + // path, and the ancestor project's own config is untouched. + expect(existsSync(join(badWorkdir, "supabase"))).toBe(false); + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }, + ); }); diff --git a/apps/cli/src/commands/experimental/workers/push/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/workers/push/SIDE_EFFECTS.md index c4af0f5100..2bb2c2d258 100644 --- a/apps/cli/src/commands/experimental/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/workers/push/SIDE_EFFECTS.md @@ -7,13 +7,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | -| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each worker's runtime, size, exposure, instances, source | -| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same worker fields | -| `/**` | any | always — packaged into the build context | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each worker's runtime, size, exposure, instances, source. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the resolved worker `source` below resolves against that SAME ancestor root | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same worker fields, with the same explicit-vs-default workdir rule | +| `/**` | any | always — packaged into the build context | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -54,12 +54,12 @@ run reports the accepted spec the deploy response returned. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/workers/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/workers/status/SIDE_EFFECTS.md index 26c7c76a48..74bc80f159 100644 --- a/apps/cli/src/commands/experimental/workers/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/workers/status/SIDE_EFFECTS.md @@ -7,15 +7,15 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the worker's source directory. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command | -| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms | -| `/` | directory | canonicalised and stat'd, to decide whether the source row is stated at all | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.json` | JSON | when present — preferred over `config.toml`; the worker's source directory. Best-effort: a config that will not load degrades to "nothing local" rather than failing the command. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the reported source directory resolves against that SAME ancestor root | +| `/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms, with the same explicit-vs-default workdir rule | +| `/` | directory | canonicalised and stat'd, to decide whether the source row is stated at all | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -42,13 +42,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/workers/workers.shared.ts b/apps/cli/src/commands/experimental/workers/workers.shared.ts index c84f152ad7..0a9e0cf375 100644 --- a/apps/cli/src/commands/experimental/workers/workers.shared.ts +++ b/apps/cli/src/commands/experimental/workers/workers.shared.ts @@ -1,7 +1,8 @@ import { join } from "node:path"; -import { loadCliConfig } from "@supabase/config/effect"; +import { findCliProjectPaths, loadCliConfig } from "@supabase/config/effect"; import { Effect, FileSystem, Option, Predicate } from "effect"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { legacyShouldSearchAncestors } from "../../../command-internal/legacy-workdir-search.ts"; import { readWorkersSection, type WorkerEntry, @@ -34,20 +35,29 @@ export interface LegacyWorkersProject { const loadWorkersProject = Effect.fnUntraced(function* (options: { readonly tomlOnly: boolean }) { const settings = yield* LegacyCliSettings; - const projectRoot = settings.workdir; - const supabaseDir = join(projectRoot, "supabase"); - // `search: false`: `settings.workdir` is already an authoritative project - // root — `--workdir`/`SUPABASE_WORKDIR` as given, else the one ancestor walk - // Go's `getProjectRoot` performs — so letting the loader climb again resolves - // `configPath` to an *ancestor* project while every path derived from - // `projectRoot` stays put. `workers new api --workdir ./bare-dir` inside - // another project is the case in point: the entry lands in the ancestor's - // `config.toml` recording `source = "supabase/workers/api"`, which resolves - // against the ancestor root to a directory the scaffold never created. + // tomlOnly (the [workers.*] entry writer) keeps `search: false` unconditionally: + // it and the workdir's own default resolution probe the same config.toml, so the + // second climb is redundant. The JSON-capable read must thread the predicate — the + // default workdir resolution only probes config.toml, so a config.json-only project + // invoked from a subdirectory relies on this climb to be found at all (CLI-2285). // - // `loadCliConfig` returns null when the directory holds no project yet, - // which is what lets `workers new` scaffold into a bare one. + // `workers new api --workdir ./bare-dir` inside another project is why + // `projectRoot` must be DERIVED from this same search rather than always + // `settings.workdir`: with an explicit workdir the predicate yields `false` + // (see `legacyShouldSearchAncestors`), so `paths` is null and `projectRoot` + // falls back to `settings.workdir` exactly as before — that scaffold-into-a- + // bare-directory behavior is preserved verbatim. Only a DEFAULTED workdir can + // ever climb here, and when it does, `projectRoot` must climb WITH `configPath` + // — otherwise a discovered ancestor's `[workers.*]` entries would resolve their + // `source` against a non-project directory. + const search = options.tomlOnly ? false : legacyShouldSearchAncestors(settings); + const paths = yield* findCliProjectPaths(settings.workdir, { search }); + const projectRoot = paths?.projectRoot ?? settings.workdir; + const supabaseDir = join(projectRoot, "supabase"); + + // `search: false`: the climb (if any) already happened above; reading + // `projectRoot`'s own config.toml again must never climb a second time. const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: options.tomlOnly, search: false }); const section = readWorkersSection(loaded?.config.workers); diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 2520568151..7b6c6b3c87 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -2,18 +2,18 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | -| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past ``; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally | -| `/supabase/functions//index.ts` | TypeScript | function source to deploy | -| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | -| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | -| configured static files | any | when `static_files` patterns match local files | -| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | -| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past `` — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally | +| `/supabase/functions//index.ts` | TypeScript | function source to deploy | +| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | +| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | +| configured static files | any | when `static_files` patterns match local files | +| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | +| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | ## Files Written diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 3a97d60d17..cb3662a64f 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1718,6 +1718,97 @@ describe("legacy functions deploy", () => { ); }); + it.live( + "does not treat an ancestor project's deno.json as this project's own import map when --workdir names a config-less subdirectory of it", + () => { + // CLI-2285: `inferFunctionsManifest`'s filesystem discovery previously + // climbed independently of the config load — no `search` option meant + // the package default (always climbing), regardless of `goConfigCompat`, + // while the config load (`loadFunctionsCliConfig`) has always used + // `search: false` for the legacy shell. Before this fix, a function + // directory with no `deno.json` of its own would still be reported as + // HAVING one — borrowed from an unrelated ANCESTOR project's own + // `deno.json` — because the manifest's filesystem walk climbed to find + // the ancestor's project root even though the config load never did. + // The resulting (wrong) import map path is then re-anchored under THIS + // project's own supabase dir, where no such file exists — failing the + // deploy outright with a spurious file-not-found, instead of correctly + // deploying the function with no import map. + const nestedWorkdir = join(tempRoot.current, "nested"); + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + if (request.url.endsWith("/functions/deploy")) { + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + }), + ); + } + return Effect.succeed(legacyJsonResponse(request, 404, { error: "not found" })); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ workdir: nestedWorkdir }), + runtimeInfo: mockRuntimeInfo({ cwd: nestedWorkdir }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + // Ancestor project: a real config.toml plus a real function with + // BOTH an entrypoint and a deno.json. + yield* Effect.tryPromise(() => + writeCliConfig(tempRoot.current, 'project_id = "ancestor-project"\n'), + ); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + // The sub-project actually deployed has its OWN entrypoint, but + // deliberately no deno.json of its own. + yield* Effect.tryPromise(() => + mkdir(join(nestedWorkdir, "supabase", "functions", "hello-world"), { + recursive: true, + }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(nestedWorkdir, "supabase", "functions", "hello-world", "index.ts"), + "Deno.serve(() => new Response())\n", + ), + ); + + yield* legacyFunctionsDeploy(baseFlags); + + const deployRequest = api.requests.find( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ); + expect(deployRequest).toBeDefined(); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + describe("docker-not-running warning styling (Go parity: deploy.go:60; only WARNING: is styled)", () => { it.live("wraps only the WARNING token, not the rest of the fallback line", () => { // Calls the shared `deployFunctions` with a marker `styleWarning` instead diff --git a/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md index 803318ac49..1804d5d310 100644 --- a/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md @@ -43,12 +43,13 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------- | -| `0` | success | -| `1` | invalid function name | -| `1` | function entrypoint already exists | -| `1` | local file write failed | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyFunctionsNewWorkdirError`) — beats slug validation and every filesystem write | +| `1` | invalid function name | +| `1` | function entrypoint already exists | +| `1` | local file write failed | ## Telemetry Events Fired @@ -80,3 +81,4 @@ Emits a structured success result event with `path`, `function_name`, and `auth` - Existing-declaration detection scans the raw `config.toml` text (`^\s*\[functions\.\]\s*$`) rather than a parsed config map. This is a deliberate design choice: config loading here is non-fatal, so a raw-text scan stays deterministic even when the file fails to parse. For all well-formed configs the two approaches agree. - IDE settings scaffolding (`.vscode`, `.idea`) only runs in `--output-format text`; json / stream-json runs are payload-only. - No Management API requests are made; all behavior is local filesystem work plus telemetry flush. +- A non-existent `--workdir`/`SUPABASE_WORKDIR` now fails before any directory or file is created (CLI-2285) — previously a typo'd `--workdir` could scaffold a fresh `supabase/functions/…` tree (plus a new `config.toml`) at the wrong path. diff --git a/apps/cli/src/commands/functions/new/new.errors.ts b/apps/cli/src/commands/functions/new/new.errors.ts index fa08417e5f..71231ba086 100644 --- a/apps/cli/src/commands/functions/new/new.errors.ts +++ b/apps/cli/src/commands/functions/new/new.errors.ts @@ -37,6 +37,23 @@ export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFuncti } } +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats slug validation and every filesystem write, + * so a typo'd `--workdir` can never scaffold a fresh `supabase/functions/…` + * tree (plus a new `config.toml`) at the wrong path. + */ +export class LegacyFunctionsNewWorkdirError extends Data.TaggedError( + "LegacyFunctionsNewWorkdirError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * Maps an arbitrary thrown cause from a filesystem write to a typed * `LegacyFunctionsNewWriteError` tagged with the given `path`. Used by the IDE diff --git a/apps/cli/src/commands/functions/new/new.handler.ts b/apps/cli/src/commands/functions/new/new.handler.ts index ebcfe874de..59cab3ceb6 100644 --- a/apps/cli/src/commands/functions/new/new.handler.ts +++ b/apps/cli/src/commands/functions/new/new.handler.ts @@ -13,11 +13,14 @@ import { Output } from "../../../shared/output/output.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { legacyBold } from "../../../command-internal/legacy-colors.ts"; +import { legacyShouldSearchAncestors } from "../../../command-internal/legacy-workdir-search.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsNewFlags } from "./new.command.ts"; import { LegacyFunctionsNewFileExistsError, LegacyFunctionsNewInvalidSlugError, + LegacyFunctionsNewWorkdirError, LegacyFunctionsNewWriteError, mapLegacyFunctionsNewWriteError, } from "./new.errors.ts"; @@ -90,10 +93,14 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) return slugs; }); -const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadCliConfig(workdir, { goViperCompat: true }).pipe( - Effect.orElseSucceed(() => null), - ); +const resolveTemplateInputs = Effect.fnUntraced(function* ( + cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, + slug: string, +) { + const loaded = yield* loadCliConfig(cliSettings.workdir, { + goViperCompat: true, + search: legacyShouldSearchAncestors(cliSettings), + }).pipe(Effect.orElseSucceed(() => null)); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { @@ -177,6 +184,10 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( const tty = yield* Tty; yield* Effect.gen(function* () { + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyFunctionsNewWorkdirError({ message: error.message })), + ); + const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName); if (invalidSlugMessage !== undefined) { return yield* Effect.fail( @@ -219,7 +230,7 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( ); } - const templateInputs = yield* resolveTemplateInputs(cliSettings.workdir, flags.functionName); + const templateInputs = yield* resolveTemplateInputs(cliSettings, flags.functionName); yield* fs .writeFileString(entrypointPath, renderLegacyFunctionsNewEntrypoint(authMode, templateInputs)) .pipe( diff --git a/apps/cli/src/commands/functions/new/new.integration.test.ts b/apps/cli/src/commands/functions/new/new.integration.test.ts index 5fdec07ce9..aaf35ed3a6 100644 --- a/apps/cli/src/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/commands/functions/new/new.integration.test.ts @@ -27,6 +27,10 @@ interface SetupOptions { readonly promptConfirmResponses?: ReadonlyArray; /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ readonly stdinInput?: string; + /** cliSettings.workdir override; defaults to the temp project root. */ + readonly workdir?: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; } function setup(options: SetupOptions = {}) { @@ -35,7 +39,11 @@ function setup(options: SetupOptions = {}) { promptConfirmResponses: options.promptConfirmResponses, }); const telemetry = mockLegacyTelemetryStateTracked(); - const cliSettings = mockLegacyCliSettings({ workdir: tempRoot.current }); + const workdir = options.workdir ?? tempRoot.current; + const cliSettings = mockLegacyCliSettings({ + workdir, + explicitWorkdir: options.explicitWorkdir ?? false, + }); const layer = Layer.mergeAll( BunServices.layer, out.layer, @@ -49,7 +57,7 @@ function setup(options: SetupOptions = {}) { Layer.succeed(LegacyYesFlag, options.yes ?? false), Layer.succeed(CliArgs, { args: [] }), ); - return { layer, out, telemetry, workdir: tempRoot.current }; + return { layer, out, telemetry, workdir }; } function exitTag(exit: Exit.Exit): string | undefined { @@ -342,4 +350,27 @@ describe("legacy functions new integration", () => { expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.live( + "fails without scaffolding anything when --workdir names a directory that does not exist at all", + () => { + // Before this fix, a typo'd --workdir would have silently created a + // fresh supabase/functions/... tree (plus a new config.toml) at the + // wrong path — the critical safety assertion here is that NOTHING was + // scaffolded once the workdir check fails first. + const badWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, telemetry } = setup({ workdir: badWorkdir, explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyFunctionsNew({ functionName: "hello-world", auth: "apikey" }), + ); + expect(exitTag(exit)).toBe("LegacyFunctionsNewWorkdirError"); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit)).toContain("failed to change workdir: chdir"); + } + expect(existsSync(join(badWorkdir, "supabase"))).toBe(false); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 58f457fff3..24740e79d0 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -111,6 +111,7 @@ Long-running raw log / error events only; there is no terminal `result` event on - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`. - Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`) and passed into `loadCliConfig`. The command does not move/hide any project files. One `process.env` mutation exists: the shared config pipeline (`legacyLoadLocalProjectContext`, shared with `deploy`/`download`/`start`) installs a project-dotenv-only `BITBUCKET_CLONE_DIR` into `process.env`. +- Config, project dotenv discovery, and function discovery all resolve from `` with no ancestor search (CLI-2285), so they can never disagree. - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index ee4e5e4b4b..fe171cbe67 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -1524,6 +1524,98 @@ describe("legacy functions serve integration", () => { }); }); + it.live( + "does not let an ancestor project's deno.json get misattributed to this project's own function when --workdir names a config-less subdirectory of it", + () => { + // CLI-2285: `resolveServeConfig` used to pass NO `search` option to + // `inferFunctionsManifest`, so it always climbed ancestors (the + // package default) regardless of `goConfigCompat`, while the config + // load right next to it already used `search: false` for the legacy + // shell. A function directory with no deno.json of its own would + // still be reported as HAVING one — borrowed from an unrelated + // ANCESTOR project's own deno.json of the same slug — because the + // manifest's filesystem walk climbed to find the ancestor's project + // root even though the config load never did. The borrowed import map + // path is then re-anchored under THIS project's own supabase dir, + // where no such file exists. Same fix, same shape of regression test, + // as deploy.integration.test.ts's "does not treat an ancestor + // project's deno.json as this project's own import map…" test. + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + const nestedWorkdir = join(tempRoot.current, "nested", "dir"); + + return Effect.gen(function* () { + // Ancestor project: a real config.toml plus a real function with + // BOTH an entrypoint and a deno.json, at the same slug the + // sub-project below serves. + yield* Effect.promise(() => writeCliConfig('project_id = "ancestor-project"\n')); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("ancestor"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + // The sub-project actually served has its OWN entrypoint, but + // deliberately no deno.json of its own — and no config.toml either, + // which is what makes it "config-less" relative to the ancestor. + yield* Effect.promise(() => + mkdir(join(nestedWorkdir, "supabase", "functions", "hello"), { recursive: true }), + ); + yield* Effect.promise(() => + writeFile( + join(nestedWorkdir, "supabase", "functions", "hello", "index.ts"), + "Deno.serve(() => new Response())\n", + ), + ); + + const { layer } = setupServe({ childSpawner, workdir: nestedWorkdir }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "create", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker create call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const functionsConfigEntry = envs.find((entry) => + entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), + ); + expect(functionsConfigEntry).toBeDefined(); + if (functionsConfigEntry === undefined) { + throw new Error("missing functions config env"); + } + const functionsConfig = JSON.parse( + functionsConfigEntry.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length), + ); + // The sub-project's own "hello" is still served — not silently + // dropped — but with no import map, since the ancestor's deno.json + // must never be borrowed for it. + expect(functionsConfig).toHaveProperty("hello"); + expect(functionsConfig.hello).not.toHaveProperty("importMapPath"); + }); + }, + ); + it.live("restarts the runtime when watched files change", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index c4747710c9..d6872131a7 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` | TOML | when selecting schemas; `--local` uses embedded defaults when the file is missing | -| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the legacy CLI | -| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | -| `/supabase/.temp/pgmeta-version` | plain text | `--local` only — overrides the pg-meta docker image tag | +| Path | Format | When | +| ------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | +| `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, `--db-url`, and the implicit linked fallback — but not when `--schema` is also given on the two flag paths, which skip the load entirely). `--local` reads config.toml through its own tolerant reader (`legacyReadDbToml`) and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`LegacyGenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | +| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the legacy CLI | +| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | +| `/supabase/.temp/pgmeta-version` | plain text | `--local` only — overrides the pg-meta docker image tag | ## Files Written @@ -59,33 +59,36 @@ timeout. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | -| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry); pins a single registry, so the ECR → GHCR → Docker Hub fallback does not apply | no (defaults to the ECR registry) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pg-meta pin from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable); a historical `.temp/pgmeta-version` pin stays on docker.io | no | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | +| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry); pins a single registry, so the ECR → GHCR → Docker Hub fallback does not apply | no (defaults to the ECR registry) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pg-meta pin from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable); a historical `.temp/pgmeta-version` pin stays on docker.io | no | +| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------- | -| `0` | success — types printed to stdout | -| `1` | no target specified (must use one flag) | -| `1` | mutually exclusive flags combined (all four Go flag groups) | -| `1` | `--postgrest-v9-compat` used without `--db-url` | -| `1` | invalid `--query-timeout` duration or invalid `--db-url` | -| `1` | `supabase start` not running (`--local`) or db inspection failed | -| `1` | API error, TLS probe failure, or pg-meta container non-zero exit | -| `1` | no container runtime found, or the pg-meta image could not be inspected or pulled from any registry | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — types printed to stdout | +| `1` | no target specified (must use one flag) | +| `1` | mutually exclusive flags combined (all four Go flag groups) | +| `1` | `--postgrest-v9-compat` used without `--db-url` | +| `1` | invalid `--query-timeout` duration or invalid `--db-url` | +| `1` | `supabase start` not running (`--local`) or db inspection failed | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyGenTypesWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`LegacyGenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | +| `1` | API error, TLS probe failure, or pg-meta container non-zero exit | +| `1` | no container runtime found, or the pg-meta image could not be inspected or pulled from any registry | ## Output diff --git a/apps/cli/src/commands/gen/types/types.errors.ts b/apps/cli/src/commands/gen/types/types.errors.ts index 8526c3a4f2..fc9749345b 100644 --- a/apps/cli/src/commands/gen/types/types.errors.ts +++ b/apps/cli/src/commands/gen/types/types.errors.ts @@ -48,3 +48,52 @@ export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( return actionability.provideFlags; } } + +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats every one of this command's own guards. + */ +export class LegacyGenTypesWorkdirError extends Data.TaggedError("LegacyGenTypesWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `loadCliConfig` failed to parse `supabase/config.toml`/`config.json`, or + * found two `[remotes.*]` blocks declaring the same `project_id`. Mirrors + * `LegacyConfigDiffLoadConfigError`'s parse-error/duplicate-remote handling + * (`config diff`'s `loadLocalConfig`) so a malformed config reports its own + * parse failure instead of the raw `CliConfigParseError`/ + * `DuplicateRemoteProjectIdError` tag leaking through as the message. + */ +export class LegacyGenTypesParseConfigError extends Data.TaggedError( + "LegacyGenTypesParseConfigError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config, on a + * path that would otherwise load one (`--linked`/`--project-id`/`--db-url`, + * or the linked fallback) — raised instead of silently falling back to the + * embedded default schemas, which would drop a declared `[api].schemas` and + * write a public-only types file at exit 0. A DEFAULTED workdir keeps the + * established tolerant fallback. + */ +export class LegacyGenTypesMissingProjectConfigError extends Data.TaggedError( + "LegacyGenTypesMissingProjectConfigError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index e8077f00a9..3118ec2f77 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,3 +1,4 @@ +import type { LoadedCliConfig } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from "effect"; @@ -32,6 +33,12 @@ import { import type { LegacyPgConnInput } from "../../../command-internal/legacy-db-connection.service.ts"; import { legacyToPostgresURL } from "../../../command-internal/legacy-postgres-url.ts"; import { legacyTempPaths } from "../../../command-internal/legacy-temp-paths.ts"; +import { + legacyMissingProjectConfigMessageEffect, + legacyRelativeConfigPath, +} from "../../../command-internal/legacy-workdir-project.ts"; +import { legacyShouldSearchAncestors } from "../../../command-internal/legacy-workdir-search.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; @@ -40,7 +47,13 @@ import { legacyRunWithPoolerFallback, } from "../../../command-internal/legacy-pooler-fallback.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; -import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; +import { + LegacyGenTypesMissingProjectConfigError, + LegacyGenTypesNetworkError, + LegacyGenTypesParseConfigError, + LegacyGenTypesUnexpectedStatusError, + LegacyGenTypesWorkdirError, +} from "./types.errors.ts"; import { legacyGetHostname } from "../../../command-internal/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { @@ -257,9 +270,53 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; - const loadConfig = () => loadCliConfig(cliSettings.workdir, { goViperCompat: true }); - const loadConfigForRef = (projectRef: string) => - loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }); + // Resolved against `cliSettings.workdir`, the root every config load in + // this handler uses. + const relativeConfigPath = (path: string) => legacyRelativeConfigPath(cliSettings.workdir, path); + + // `projectRef` is only ever passed for the `--linked`/`--project-id` paths + // below (so a matching `[remotes.*]` overlay is merged in the SAME load); + // omitted for the `--local`/`--db-url` paths, matching `loadCliConfig`'s + // own optional `projectRef`. + const loadConfig = (projectRef?: string) => + loadCliConfig(cliSettings.workdir, { + ...(projectRef === undefined ? {} : { projectRef }), + goViperCompat: true, + search: legacyShouldSearchAncestors(cliSettings), + }).pipe( + // `cause.path` names the file that actually failed to parse — `loadCliConfig` + // probes `supabase/config.json` before falling back to `supabase/config.toml` + // (`findCliProjectPaths`), so hardcoding the `.toml` name here would mislabel a + // broken `config.json`. Caught regardless of `explicitWorkdir` — a malformed + // config is a parse failure, not the "no project here" case + // `requireProjectConfigWhenExplicit` handles, so it must run before that + // flatMap ever sees the (by-then-already-failed) load. + Effect.catchTag( + "CliConfigParseError", + (cause) => + new LegacyGenTypesParseConfigError({ + message: `failed to parse ${relativeConfigPath(cause.path)}: ${String(cause.cause)}`, + }), + ), + Effect.catchTag( + "DuplicateRemoteProjectIdError", + (cause) => new LegacyGenTypesParseConfigError({ message: cause.message }), + ), + Effect.flatMap(requireProjectConfigWhenExplicit), + ); + + // CLI-2285: an explicit --workdir that holds no project must not silently + // resolve to the embedded default schemas (dropping a declared [api].schemas + // and writing a public-only types file, exit 0). A DEFAULTED workdir keeps + // today's tolerant fallback. + const requireProjectConfigWhenExplicit = (loaded: LoadedCliConfig | null) => + loaded === null && cliSettings.explicitWorkdir + ? Effect.gen(function* () { + return yield* new LegacyGenTypesMissingProjectConfigError({ + message: yield* legacyMissingProjectConfigMessageEffect(cliSettings), + }); + }) + : Effect.succeed(loaded); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); @@ -546,7 +603,15 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); yield* Effect.gen(function* () { - // The command's own guard runs first, then flag-group validation — so + // The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a + // directory before the command's own guard or flag-group validation — + // the query-timeout parse failure above still precedes it (parsed at + // flag-parse time, before the telemetry context). + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacyGenTypesWorkdirError({ message: error.message })), + ); + + // The command's own guard runs next, then flag-group validation — so // this guard's error wins when both apply (e.g. `--local --linked // --postgrest-v9-compat`). Both run AFTER the telemetry context is // already installed, unlike the query-timeout parse failure above, so @@ -642,7 +707,12 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } if (Option.isSome(flags.dbUrl)) { - const loaded = yield* loadConfig(); + // Mirrors the `--linked`/`--project-id` branches below: an explicit + // `--schema` makes the config load's only output (the schema fallback) + // unused, so skip it entirely — an explicit workdir with no project + // must not fail a `--db-url --schema ...` invocation that never needed + // the config in the first place. + const loaded = schemas.length > 0 ? null : yield* loadConfig(); const direct = yield* parseDatabaseUrl(flags.dbUrl.value); const includedSchemas = ( schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas ?? []) @@ -663,7 +733,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (flags.linked) { const ref = yield* projectRef.resolve(Option.none()); - const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(ref); + const loaded = schemas.length > 0 ? null : yield* loadConfig(ref); yield* runProjectTypes( ref, schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), @@ -674,7 +744,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (Option.isSome(flags.projectId)) { const ref = yield* projectRef.resolve(flags.projectId); - const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(ref); + const loaded = schemas.length > 0 ? null : yield* loadConfig(ref); yield* runProjectTypes( ref, schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), @@ -696,7 +766,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le return Effect.fail(cause); }), ); - const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(resolvedRef); + const loaded = schemas.length > 0 ? null : yield* loadConfig(resolvedRef); yield* runProjectTypes( resolvedRef, schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 2295cf2566..5e444e01cf 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -210,6 +210,7 @@ function setup( opts: { readonly workdir?: string; readonly skipConfig?: boolean; + readonly explicitWorkdir?: boolean; readonly projectId?: Option.Option; readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; @@ -347,6 +348,7 @@ function setup( api, cliSettings: mockLegacyCliSettings({ workdir, + explicitWorkdir: opts.explicitWorkdir ?? false, projectId: opts.projectId ?? Option.none(), }), telemetry: telemetry.layer, @@ -769,6 +771,187 @@ describe("legacy gen types", () => { }, ); + it.live( + "fails instead of picking up an ancestor project's configured api schemas when --workdir names a subdirectory with no config of its own", + () => { + // CLI-2285 regression: an explicit --workdir is authoritative and must + // never let the schema-resolution config load climb past it — the + // ancestor (root) genuinely declares [api].schemas and the + // subdirectory genuinely has no supabase/ of its own. Silently falling + // back to the built-in "public" default (dropping the ancestor's + // schemas without a hint) would write a wrong types file at exit 0, so + // this now hard-fails before any network call instead. + const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + writeConfig( + root, + ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + ); + const sub = join(root, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api } = setup({ + workdir: sub, + skipConfig: true, + explicitWorkdir: true, + projectId: Option.some(LEGACY_VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--workdir/SUPABASE_WORKDIR is used exactly as given and no ancestor directory is searched", + ); + } + expect(api.requests).toHaveLength(0); + }); + }, + ); + + it.live( + "a defaulted workdir still picks up an ancestor project's configured api schemas from a subdirectory", + () => { + // Complements the regression above: a DEFAULTED (unset) --workdir must + // keep climbing so the ancestor's declared [api].schemas still resolves + // from a config-less subdirectory — proving the hard-fail fix above + // didn't break the legitimate default-climb case. + const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + writeConfig( + root, + ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + ); + const sub = join(root, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, api } = setup({ + workdir: sub, + skipConfig: true, + explicitWorkdir: false, + projectId: Option.some(LEGACY_VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) })).pipe( + Effect.provide(layer), + ); + + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: LEGACY_VALID_REF, included_schemas: "public,ancestor_only" }, + }); + }); + }, + ); + + it.live( + "--db-url --schema succeeds on an explicit --workdir with no project of its own, since an explicit schema never needs the config load", + () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + // The --db-url branch's config load exists only to fall back to + // a declared [api].schemas when --schema is absent — with an + // explicit --schema that load's result is unused, so it's + // skipped entirely, and a config-less explicit --workdir (here, + // a subdirectory of an unrelated ancestor project) must not + // fail an invocation that never needed the config. + const docker = captureDockerRun(); + const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + writeConfig( + root, + ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + ); + const sub = join(root, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer } = setup({ + workdir: sub, + skipConfig: true, + explicitWorkdir: true, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)), + ); + + // The explicit --schema wins, not the (unreachable) ancestor's + // declared schema. + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any config load", + () => { + const missing = join(tmpdir(), "supabase-gen-types-does-not-exist", "nonexistent"); + const { layer, api } = setup({ + workdir: missing, + skipConfig: true, + explicitWorkdir: true, + projectId: Option.some(LEGACY_VALID_REF), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("LegacyGenTypesWorkdirError"); + expect(String(exit.cause)).toContain("failed to change workdir: chdir"); + } + expect(api.requests).toHaveLength(0); + }); + }, + ); + + it.live( + "surfaces a real error message when supabase/config.toml is malformed, not the raw CliConfigParseError tag", + () => { + // CLI-2285 Round 3: `loadConfigForRef` now catches `CliConfigParseError` + // and maps it to `LegacyGenTypesParseConfigError` with a real message. + // Before this fix the raw `CliConfigParseError` tag propagated unmapped, + // so an assertion that only checked `Exit.isFailure` would not catch a + // re-regression of that leak — the message content itself is the point. + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-malformed-")); + writeConfig(workdir, 'project_id = "unterminated\n'); + const { layer, api } = setup({ + workdir, + skipConfig: true, + projectId: Option.some(LEGACY_VALID_REF), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const rendered = String(exit.cause); + expect(rendered).toContain("LegacyGenTypesParseConfigError"); + expect(rendered).toContain("failed to parse"); + expect(rendered).toContain(join("supabase", "config.toml")); + expect(rendered).not.toContain("CliConfigParseError"); + } + expect(api.requests).toHaveLength(0); + }); + }, + ); + it.live("fails when no target resolves", () => { const { layer } = setup(); diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 589186edb8..66e9cf6c02 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -6,14 +6,14 @@ stack is used; with `--linked` the remote project is used. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | -| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | -| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.env*`, `/.env*` | dotenv | once per run, unless the caller already resolved the map (`db reset --local` and `start` pass theirs through): resolves `SUPABASE_YES` for the overwrite/prune prompts on either target (CLI-1878) and, on local runs, the `SUPABASE_API_*` overrides for the gateway URL and TLS material (#6452) | +| Path | Format | When | +| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect. With an explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project config, the standalone `seed buckets` command now fails (`LegacySeedMissingProjectConfigError`) instead of authenticating and seeding nothing while still exiting `0`; a DEFAULTED workdir keeps the embedded-default fallback, and `start`/`db reset`'s reuse of this seeding core (which supplies its own already-resolved config) is unaffected either way | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/.env*`, `/.env*` | dotenv | once per run, unless the caller already resolved the map (`db reset --local` and `start` pass theirs through): resolves `SUPABASE_YES` for the overwrite/prune prompts on either target (CLI-1878) and, on local runs, the `SUPABASE_API_*` overrides for the gateway URL and TLS material (#6452) | ## Files Written @@ -84,6 +84,8 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | Code | Condition | | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (including the empty-config short-circuit, provided the local `[api]` checks pass: override decode, non-zero enabled port, TLS cert/key pairing) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacySeedWorkdirError`) — beats the `--project-ref` guard and every network call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`LegacySeedMissingProjectConfigError`) — precedes the api-keys fetch and any Storage call | | `1` | `supabase/config.toml` parse failure | | `1` | `auth.jwt_secret` (or `SUPABASE_AUTH_JWT_SECRET`) set but shorter than 16 characters | | `1` | `[storage.buckets]` entry has an invalid name (contains characters outside the allowed bucket-name pattern) | diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index ff04a494cf..b89dcec8a9 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -42,3 +42,33 @@ export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( return actionability.provideFlags; } } + +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats the `--project-ref` guard and every + * network call. + */ +export class LegacySeedWorkdirError extends Data.TaggedError("LegacySeedWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config — + * raised instead of silently falling back to the embedded default (empty) + * bucket configuration, which would authenticate and seed nothing while + * still exiting 0. + */ +export class LegacySeedMissingProjectConfigError extends Data.TaggedError( + "LegacySeedMissingProjectConfigError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 4d9973321f..16944c6a86 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -1,13 +1,20 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { legacySeedBucketsRun } from "../../../command-internal/legacy-seed-buckets.ts"; +import { legacyRequireExplicitWorkdirProject } from "../../../command-internal/legacy-workdir-project.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../../command-internal/legacy-workdir-validation.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -import { LegacySeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; +import { + LegacySeedMissingProjectConfigError, + LegacySeedMutuallyExclusiveFlagsError, + LegacySeedWorkdirError, +} from "./buckets.errors.ts"; /** * `supabase seed buckets` — seeds Storage buckets from @@ -28,6 +35,8 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const cliArgs = yield* CliArgs; + const cliSettings = yield* LegacyCliSettings; + const fs = yield* FileSystem.FileSystem; // Set once --linked resolves a ref; drives the post-run linked-project cache // write + org/project group identify (`cmd/root.go`'s `ensureProjectGroupsCached`, @@ -36,6 +45,10 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + yield* legacyValidateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( + Effect.mapError((error) => new LegacySeedWorkdirError({ message: error.message })), + ); + // Resolve the project ref for --linked BEFORE loading config, so that the // matching `[remotes.]` override (whose `project_id == ref`) is merged // over the base config by `loadCliConfig`. The target is selected from @@ -56,6 +69,18 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( ); } + // An explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project config + // fails HERE, before the api-keys fetch and any Storage call — fixes the + // "authenticates, seeds nothing, exits 0" bug. `start`/`db reset` never + // reach this handler (they call `legacySeedBucketsRun` directly), so + // their behavior is unaffected. A DEFAULTED workdir is untouched — see + // `legacyRequireExplicitWorkdirProject`'s own doc comment. + yield* legacyRequireExplicitWorkdirProject(cliSettings).pipe( + Effect.mapError( + (error) => new LegacySeedMissingProjectConfigError({ message: error.message }), + ), + ); + const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = isLinked ? yield* projectRefResolver.loadProjectRef(flags.projectRef) : ""; linkedRef = projectRef; diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 82f0d8072a..8b37fbbb1e 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { afterEach, beforeEach } from "vitest"; +import { loadCliConfig } from "@supabase/config/internal"; import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -73,6 +74,8 @@ function setupLegacySeedBuckets( readonly linkedFails?: boolean; /** When set, the Management API `getProjectApiKeys` call fails with this error. */ readonly apiKeysFail?: HttpClientError.HttpClientError; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; }, ) { if (opts.toml !== undefined) { @@ -189,7 +192,7 @@ function setupLegacySeedBuckets( out.layer, httpLayer, telemetry.layer, - mockLegacyCliSettings({ workdir }), + mockLegacyCliSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), BunServices.layer, // Seed-bucket prompts model an interactive user answering via `confirm`. mockTty({ stdinIsTty: true, stdoutIsTty: false }), @@ -2633,4 +2636,96 @@ describe("legacy seed buckets", () => { ).toBe(true); }); }); + + it.live( + "fails before the api-keys fetch when --workdir names a config-less subdirectory of a real ancestor project", + () => { + // CLI-2285 regression: the ancestor project genuinely has a valid + // config.toml declaring a bucket, and the subdirectory genuinely has + // none of its own — an EXPLICIT --workdir must never silently climb + // to the ancestor's config and seed buckets there. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n[storage.buckets.test]\npublic = true\n', + ); + const sub = join(tmp.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, requests, telemetry } = setupLegacySeedBuckets(sub, { + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacySeedMissingProjectConfigError"); + // Fires before any credential/api-keys resolution. + expect(requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }, + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any credential resolution", + () => { + const missing = join(tmp.current, "does-not-exist"); + const { layer, requests } = setupLegacySeedBuckets(missing, { explicitWorkdir: true }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacySeedWorkdirError"); + expect(JSON.stringify(exit)).toContain("failed to change workdir: chdir"); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live( + "legacySeedBucketsRun succeeds with a caller-supplied resolvedConfig even when cliSettings.explicitWorkdir is true", + () => { + // `start`/`db reset` never reach `buckets.handler.ts`'s own + // `legacyRequireExplicitWorkdirProject` guard — they call this shared + // core directly with an already-resolved `resolvedConfig`, bypassing + // the reload entirely. This regression guard proves that reuse path + // stays untouched by the CLI-2285 fix even when the settings passed + // happen to carry `explicitWorkdir: true`. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n[storage.buckets.test]\npublic = true\n', + ); + const { layer, requests } = setupLegacySeedBuckets(tmp.current, { + explicitWorkdir: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + return Effect.gen(function* () { + const loaded = yield* loadCliConfig(tmp.current, { + goViperCompat: true, + search: false, + }).pipe(Effect.provide(BunServices.layer)); + if (loaded === null) { + throw new Error("test setup: config.toml failed to load"); + } + const exit = yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + resolvedConfig: { config: loaded.config, document: loaded.document }, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + requests.some((r) => r.method === "POST" && r.url.endsWith("/storage/v1/bucket")), + ).toBe(true); + }); + }, + ); }); diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 64736fffb3..949621b9c7 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -68,6 +68,7 @@ function setup( accessToken: Option.none(), projectId: Option.none(), workdir: opts.workdir ?? process.cwd(), + explicitWorkdir: false, userAgent: "SupabaseCLI/test", }), ), diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 7a586c1abb..882b5d979e 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -5,14 +5,14 @@ Copies objects between local paths and the Storage service. The scheme of `src`/ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | -| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | +| Path | Format | When | +| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`LegacyStorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | +| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written @@ -52,6 +52,8 @@ override family — same roles as `storage ls`. | Code | Condition | | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyStorageWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`LegacyStorageMissingProjectConfigError`) | | `1` | invalid/parse url, unsupported operation (local→local), copy-between-buckets, object-not-found (recursive download), file create/read failure, API non-2xx, network, auth, config parse | | `1` | `--project-ref` set with `--local` (see Notes) | diff --git a/apps/cli/src/commands/storage/cp/cp.handler.ts b/apps/cli/src/commands/storage/cp/cp.handler.ts index 4c324fb120..9efae1cedc 100644 --- a/apps/cli/src/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/commands/storage/cp/cp.handler.ts @@ -30,7 +30,11 @@ import { legacyGoUrlParse, legacySplitBucketPrefix, } from "../../../command-internal/legacy-storage-url.ts"; -import { legacyConnectStorageGateway, legacyLoadStorageConfig } from "../storage.frame.ts"; +import { + legacyAssertStorageWorkdir, + legacyConnectStorageGateway, + legacyLoadStorageConfig, +} from "../storage.frame.ts"; import { LegacyStorageConfigError } from "../../../command-internal/legacy-storage-credentials.errors.ts"; import { LegacyStorageCopyBetweenBucketsError, @@ -82,6 +86,8 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + yield* legacyAssertStorageWorkdir(cliSettings.workdir); + // `--project-ref` never implies `--linked` and must not be silently // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. @@ -96,7 +102,7 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; - const loaded = yield* legacyLoadStorageConfig(cliSettings.workdir, projectRef); + const loaded = yield* legacyLoadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index ae4950bdf1..a5ee8cc3cb 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -4,13 +4,13 @@ Lists objects/buckets by path prefix against the Storage gateway (local stack or ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | +| Path | Format | When | +| --------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`LegacyStorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -47,11 +47,13 @@ Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `s ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------- | -| `0` | success | -| `1` | invalid URL / url-parse error / API non-2xx / network / auth / config parse | -| `1` | `--project-ref` set with `--local` (see Notes) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyStorageWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`LegacyStorageMissingProjectConfigError`) | +| `1` | invalid URL / url-parse error / API non-2xx / network / auth / config parse | +| `1` | `--project-ref` set with `--local` (see Notes) | ## Output diff --git a/apps/cli/src/commands/storage/ls/ls.handler.ts b/apps/cli/src/commands/storage/ls/ls.handler.ts index dbebdc1eff..d8ac37a4b1 100644 --- a/apps/cli/src/commands/storage/ls/ls.handler.ts +++ b/apps/cli/src/commands/storage/ls/ls.handler.ts @@ -7,6 +7,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { Output } from "../../../shared/output/output.service.ts"; import { legacyIterateStoragePaths, legacyIterateStoragePathsAll } from "../storage.iterate.ts"; import { + legacyAssertStorageWorkdir, legacyConnectStorageGateway, legacyLoadStorageConfig, legacyParseStorageUrlEffect, @@ -33,6 +34,8 @@ export const legacyStorageLs = Effect.fn("legacy.storage.ls")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + yield* legacyAssertStorageWorkdir(cliSettings.workdir); + // `--project-ref` never implies `--linked` and must not be silently // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. @@ -53,7 +56,7 @@ export const legacyStorageLs = Effect.fn("legacy.storage.ls")(function* ( // Config is always loaded; a `[remotes.*]` match prints the override // line. - const loaded = yield* legacyLoadStorageConfig(cliSettings.workdir, projectRef); + const loaded = yield* legacyLoadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } diff --git a/apps/cli/src/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/commands/storage/ls/ls.integration.test.ts index 0a0fd221fd..3870ca0fa1 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { LEGACY_VALID_REF } from "../../../../tests/helpers/legacy-mocks.ts"; import { setupLegacyStorage } from "../../../../tests/helpers/legacy-storage.ts"; @@ -10,6 +12,12 @@ import type { LegacyStorageLsFlags } from "./ls.command.ts"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +function writeAncestorConfig(root: string, toml: string): void { + const dir = join(root, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.toml"), toml); +} + function lsFlags( opts: { path?: string; recursive?: boolean; local?: boolean } = {}, ): LegacyStorageLsFlags { @@ -299,6 +307,99 @@ describe("legacy storage ls", () => { expect(out.stderrText).not.toContain("Loading page"); }); }); + + it.live( + "fails with a missing-project error when --workdir names a config-less subdirectory of a real ancestor project", + () => { + // CLI-2285 regression, `legacyLoadStorageConfig`'s shared path: the + // ancestor project genuinely has a valid config.toml, and the + // subdirectory genuinely has none of its own — an EXPLICIT --workdir + // must never silently climb to the ancestor's config. + writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); + const sub = join(tmp.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, requests } = setupLegacyStorage(sub, { + local: true, + explicitWorkdir: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyStorageMissingProjectConfigError"); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live( + "a remote (--linked) target with the same config-less explicit workdir still succeeds", + () => { + // The missing-project hard-fail is LOCAL-only: `legacyResolveStorageCredentials` + // never reads local config on the remote path (Management API credentials + // only), so a config-less explicit workdir poses none of the "retargets a + // different local stack" risk the local-target hard-fail guards against. + writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); + const sub = join(tmp.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, requests } = setupLegacyStorage(sub, { + explicitWorkdir: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs(lsFlags({ local: false })).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + requests.some((r) => r.url.startsWith(`https://${LEGACY_VALID_REF}.supabase.co`)), + ).toBe(true); + }); + }, + ); + + it.live( + "hints at the ancestor's --workdir when it genuinely has a project (shared helper propagation)", + () => { + // Confirms `legacyMissingProjectConfigMessageEffect`'s "Did you mean" + // hint is not `config diff`-specific wiring — the full regression and + // its negative counterpart are pinned in + // config/diff/diff.integration.test.ts; this only proves the shared + // helper reaches storage's own missing-project message too. + writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); + const sub = join(tmp.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, requests } = setupLegacyStorage(sub, { + local: true, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain(`Did you mean --workdir ${tmp.current}?`); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any config load", + () => { + const missing = join(tmp.current, "does-not-exist"); + const { layer, requests } = setupLegacyStorage(missing, { + local: true, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyStorageWorkdirError"); + expect(JSON.stringify(exit)).toContain("failed to change workdir: chdir"); + expect(requests).toHaveLength(0); + }); + }, + ); }); function hasOffset(body: unknown): boolean { diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 24a18b074c..07e4e9c8f9 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -6,13 +6,13 @@ A direct move that returns `not_found` falls back to a recursive per-object move ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`LegacyStorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path, to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -47,6 +47,8 @@ override family — same roles as `storage ls`. | Code | Condition | | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyStorageWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`LegacyStorageMissingProjectConfigError`) | | `1` | invalid/parse url, missing object path (both roots), cross-bucket move, object-not-found (recursive empty), API non-2xx, network, auth, config parse | | `1` | `--project-ref` set with `--local` (see Notes) | diff --git a/apps/cli/src/commands/storage/mv/mv.handler.ts b/apps/cli/src/commands/storage/mv/mv.handler.ts index 7d902660bd..625bca873c 100644 --- a/apps/cli/src/commands/storage/mv/mv.handler.ts +++ b/apps/cli/src/commands/storage/mv/mv.handler.ts @@ -11,6 +11,7 @@ import type { LegacyStorageGateway } from "../../../command-internal/legacy-stor import { LegacyStorageGatewayStatusError } from "../../../command-internal/legacy-storage-gateway.errors.ts"; import { legacySplitBucketPrefix } from "../../../command-internal/legacy-storage-url.ts"; import { + legacyAssertStorageWorkdir, legacyConnectStorageGateway, legacyLoadStorageConfig, legacyParseStorageUrlEffect, @@ -42,6 +43,8 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + yield* legacyAssertStorageWorkdir(cliSettings.workdir); + // `--project-ref` never implies `--linked` and must not be silently // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. @@ -56,7 +59,7 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; - const loaded = yield* legacyLoadStorageConfig(cliSettings.workdir, projectRef); + const loaded = yield* legacyLoadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index ed646ff99f..7bbccf65fd 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -6,13 +6,13 @@ when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878); on the local path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`LegacyStorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878); on the local path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) | ## Files Written @@ -51,6 +51,8 @@ read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` | Code | Condition | | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (including a declined confirmation, and a tolerated `Bucket not found`) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`LegacyStorageWorkdirError`) — beats every other guard, including any `DELETE` call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`LegacyStorageMissingProjectConfigError`) — also beats any `DELETE` call | | `1` | invalid/parse url, missing bucket (root path), missing `-r` flag (directory or no args), object-not-found (recursive empty prefix), API non-2xx, network, auth, config parse | | `1` | `--project-ref` set with `--local` (see Notes) | diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 6affb7b07b..e47b2f4f66 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -19,6 +19,7 @@ import { legacyStorageIsDir, } from "../../../command-internal/legacy-storage-url.ts"; import { + legacyAssertStorageWorkdir, legacyConnectStorageGateway, legacyLoadStorageConfig, legacyParseStorageUrlEffect, @@ -67,6 +68,8 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + yield* legacyAssertStorageWorkdir(cliSettings.workdir); + // Resolve the project ref BEFORE reading the project `.env`: the // linked-project ref must be resolved strictly before the config load // (the `.env` work). An unlinked workdir must fail fast with the @@ -92,7 +95,7 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( // auto-confirm here too. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - const loaded = yield* legacyLoadStorageConfig(cliSettings.workdir, projectRef); + const loaded = yield* legacyLoadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index 26f78c1a87..1c155f78f7 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach } from "vitest"; import { setupLegacyStorage } from "../../../../tests/helpers/legacy-storage.ts"; import { LEGACY_VALID_REF, useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyStorageRm } from "./rm.handler.ts"; +function writeAncestorConfig(root: string, toml: string): void { + const dir = join(root, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.toml"), toml); +} + const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; @@ -656,6 +664,91 @@ describe("legacy storage rm", () => { }); }); + it.live( + "does not delete anything when --workdir names a config-less subdirectory of a real ancestor project", + () => { + // CLI-2285 regression, destructive-command variant: the ancestor + // project's config.toml declares a non-default [api] port — if this + // silently climbed to it, `storage rm -r` would target whatever + // (possibly running) local stack that ancestor points at. An EXPLICIT + // --workdir must hard-fail before the gateway is ever built, so no + // DELETE is ever issued. + writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); + const sub = join(tmp.current, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, requests } = setupLegacyStorage(sub, { + local: true, + yes: true, + explicitWorkdir: true, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyStorageMissingProjectConfigError"); + expect(requests.some((r) => r.method === "DELETE")).toBe(false); + expect(requests).toHaveLength(0); + }); + }, + ); + + it.live( + "a defaulted workdir with no project anywhere still proceeds using the embedded default config", + () => { + // Mirrors the regression above with explicitWorkdir flipped: a + // DEFAULTED workdir must keep its established tolerant fallback + // (`legacyLoadStorageConfig`'s `decodeDefaultCliConfig({})` branch) + // rather than hard-failing — the deletion still proceeds. + const { layer, requests } = setupLegacyStorage(tmp.current, { + local: true, + yes: true, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.method === "DELETE")).toBe(true); + }); + }, + ); + + it.live( + "an explicit --workdir naming a directory that does not exist at all fails before any credential resolution", + () => { + const missing = join(tmp.current, "does-not-exist"); + const { layer, requests } = setupLegacyStorage(missing, { + local: true, + yes: true, + explicitWorkdir: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyStorageWorkdirError"); + expect(JSON.stringify(exit)).toContain("failed to change workdir: chdir"); + expect(requests).toHaveLength(0); + }); + }, + ); + it.live("emits a { deleted, buckets_deleted } result in stream-json mode", () => { const { layer, out } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/storage/storage.errors.ts b/apps/cli/src/commands/storage/storage.errors.ts index bc33b34d88..fdbfac26ed 100644 --- a/apps/cli/src/commands/storage/storage.errors.ts +++ b/apps/cli/src/commands/storage/storage.errors.ts @@ -202,3 +202,32 @@ export class LegacyStorageMutuallyExclusiveFlagsError extends Data.TaggedError( return actionability.provideFlags; } } + +/** + * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a + * directory (`legacyValidateWorkdirIsDirectory`). Only reachable when the + * user explicitly set it — beats every other guard in `ls`/`mv`/`rm`/`cp`. + */ +export class LegacyStorageWorkdirError extends Data.TaggedError("LegacyStorageWorkdirError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config — + * raised instead of silently falling back to the embedded default config, + * whose default `api.port` could otherwise point the operation at a + * different, possibly running, local stack. + */ +export class LegacyStorageMissingProjectConfigError extends Data.TaggedError( + "LegacyStorageMissingProjectConfigError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index fcb761b4ff..8658604748 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config/effect"; import { loadCliConfig, type InternalLoadCliConfigOptions } from "@supabase/config/internal"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { @@ -17,7 +17,15 @@ import { legacyParseStorageUrl, } from "../../command-internal/legacy-storage-url.ts"; import { LegacyStorageConfigError } from "../../command-internal/legacy-storage-credentials.errors.ts"; -import { LegacyStorageInvalidUrlError, LegacyStorageUrlParseError } from "./storage.errors.ts"; +import { legacyMissingProjectConfigMessageEffect } from "../../command-internal/legacy-workdir-project.ts"; +import { legacyShouldSearchAncestors } from "../../command-internal/legacy-workdir-search.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../command-internal/legacy-workdir-validation.ts"; +import { + LegacyStorageInvalidUrlError, + LegacyStorageMissingProjectConfigError, + LegacyStorageUrlParseError, + LegacyStorageWorkdirError, +} from "./storage.errors.ts"; /** * Shared plumbing for the four `storage` subcommands. Each handler resolves the @@ -37,17 +45,29 @@ interface LegacyLoadedStorageConfig { /** * Load `supabase/config.toml`: a parse failure aborts * (`LegacyStorageConfigError`); a missing file falls back to the embedded - * defaults. When a `[remotes.]` block matches the linked ref, - * `appliedRemote` carries its name so the caller can print the - * `Loading config override:` line. + * defaults — EXCEPT for a LOCAL target (`projectRef === ""`) with an + * explicitly-set `--workdir`/`SUPABASE_WORKDIR`, where it hard-fails instead + * (`LegacyStorageMissingProjectConfigError`): the embedded default `api.port` + * could otherwise retarget a local `storage rm -r` (or any other operation) + * at a different, possibly running, local stack. A REMOTE target + * (`--project-ref`/`--linked`) never hard-fails on this, explicit workdir or + * not: `legacyResolveStorageCredentials` doesn't read `config` at all on that + * path (Management API credentials only), so a config-less workdir poses no + * such risk there — it would only cost the (cosmetic) `[remotes.*]` override + * line. A DEFAULTED workdir keeps the established tolerant fallback either + * way. When a `[remotes.]` block matches the linked ref, `appliedRemote` + * carries its name so the caller can print the `Loading config override:` + * line. */ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( - workdir: string, + cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, projectRef: string, ) { const loadOptions: InternalLoadCliConfigOptions = - projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; - const loaded = yield* loadCliConfig(workdir, loadOptions).pipe( + projectRef !== "" + ? { projectRef, goViperCompat: true, search: legacyShouldSearchAncestors(cliSettings) } + : { goViperCompat: true, search: legacyShouldSearchAncestors(cliSettings) }; + const loaded = yield* loadCliConfig(cliSettings.workdir, loadOptions).pipe( Effect.catchTag( "CliConfigParseError", (cause) => @@ -57,6 +77,11 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( ), ); if (loaded === null) { + if (cliSettings.explicitWorkdir && projectRef === "") { + return yield* new LegacyStorageMissingProjectConfigError({ + message: yield* legacyMissingProjectConfigMessageEffect(cliSettings), + }); + } return { config: decodeDefaultCliConfig({}), document: undefined, @@ -70,6 +95,20 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( } satisfies LegacyLoadedStorageConfig; }); +/** + * Validates the resolved `--workdir`/`SUPABASE_WORKDIR` exists and is a + * directory (`legacyValidateWorkdirIsDirectory`), mapping into the shared + * `LegacyStorageWorkdirError` — hoisted here (rather than duplicated across + * `ls`/`mv`/`rm`/`cp`) since `ls`/`mv` don't otherwise need `FileSystem` in + * scope. + */ +export const legacyAssertStorageWorkdir = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + yield* legacyValidateWorkdirIsDirectory(workdir, fs).pipe( + Effect.mapError((error) => new LegacyStorageWorkdirError({ message: error.message })), + ); +}); + /** * Resolve Storage credentials and run `body` against a freshly-built gateway, * with the `FetchHttpClient.Fetch` override applied to the gateway calls only diff --git a/apps/cli/src/config/legacy-cli-settings.layer.ts b/apps/cli/src/config/legacy-cli-settings.layer.ts index 9e756dde9a..536a22d374 100644 --- a/apps/cli/src/config/legacy-cli-settings.layer.ts +++ b/apps/cli/src/config/legacy-cli-settings.layer.ts @@ -88,6 +88,19 @@ function resolveProfile( * fallback) operates on a real directory name, not a relative-path fragment * like `.` (which would sanitize to an empty project id and build a bare, * all-projects-matching Docker label filter). + * + * The returned `explicit` flag is what lets JSON-capable config loads + * (`config diff`/`config push`/`config pull`/`gen types`/etc — sites that do + * NOT pass `tomlOnly: true`) skip the second ancestor search that + * `@supabase/config`'s `loadCliConfig`/`findCliProjectPaths` would otherwise + * perform by default. It is true iff this function used the flag/env value + * verbatim without climbing. + * + * `legacyPflagWorkdirValue` (`command-internal/legacy-pflag-reconcile.ts`) is + * a similar-looking pflag-semantics predicate used for a different purpose + * (SSO/dotenv precedence) and deliberately handles a changed-but-empty + * `--workdir=` differently (treats it as explicit-but-falls-through-to-walk-up, + * never to env) — the two are intentionally NOT unified. */ function resolveWorkdir( flagValue: Option.Option, @@ -95,24 +108,24 @@ function resolveWorkdir( cwd: string, configTomlExists: (path: string) => Effect.Effect, path: Path.Path, -): Effect.Effect { +): Effect.Effect<{ readonly workdir: string; readonly explicit: boolean }> { return Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return path.resolve(cwd, flagValue.value); + return { workdir: path.resolve(cwd, flagValue.value), explicit: true }; } if (envValue !== undefined && envValue.length > 0) { - return path.resolve(cwd, envValue); + return { workdir: path.resolve(cwd, envValue), explicit: true }; } let current = cwd; // Walk up until we hit a directory containing supabase/config.toml or the FS root. while (true) { const candidate = path.join(current, "supabase", "config.toml"); if (yield* configTomlExists(candidate)) { - return current; + return { workdir: current, explicit: false }; } const parent = path.dirname(current); if (parent === current) { - return cwd; + return { workdir: cwd, explicit: false }; } current = parent; } @@ -169,7 +182,7 @@ export const legacyCliSettingsLayer = Layer.unwrap( ? Option.none() : Option.some(rawProjectId); - const workdir = yield* resolveWorkdir( + const { workdir, explicit: explicitWorkdir } = yield* resolveWorkdir( workdirFlag, env["SUPABASE_WORKDIR"], runtimeInfo.cwd, @@ -188,6 +201,7 @@ export const legacyCliSettingsLayer = Layer.unwrap( accessToken, projectId, workdir, + explicitWorkdir, userAgent, }); }), diff --git a/apps/cli/src/config/legacy-cli-settings.layer.unit.test.ts b/apps/cli/src/config/legacy-cli-settings.layer.unit.test.ts index ef82d0352b..1a2ceccb33 100644 --- a/apps/cli/src/config/legacy-cli-settings.layer.unit.test.ts +++ b/apps/cli/src/config/legacy-cli-settings.layer.unit.test.ts @@ -390,6 +390,10 @@ describe("legacyCliSettingsLayer", () => { Effect.gen(function* () { const config = yield* LegacyCliSettings; expect(config.workdir).toBe("/flag/workdir"); + // An explicit non-empty --workdir is used verbatim — CLI-2285: this is + // what lets `legacyShouldSearchAncestors` skip the second, un-Go-like + // ancestor climb inside `loadCliConfig`. + expect(config.explicitWorkdir).toBe(true); }).pipe( Effect.provide( makeLayer({ @@ -405,11 +409,45 @@ describe("legacyCliSettingsLayer", () => { Effect.gen(function* () { const config = yield* LegacyCliSettings; expect(config.workdir).toBe("/env/workdir"); + expect(config.explicitWorkdir).toBe(true); }).pipe( Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot })), ), ); + // A --workdir flag present but set to the EMPTY string is treated as + // absent here (distinct from `legacyPflagWorkdirValue`'s handling of the + // same input elsewhere), so a non-empty SUPABASE_WORKDIR still wins and is + // still explicit. + it.effect( + "an empty --workdir flag falls through to SUPABASE_WORKDIR env, which is still explicit", + () => + Effect.gen(function* () { + const config = yield* LegacyCliSettings; + expect(config.workdir).toBe("/env/workdir"); + expect(config.explicitWorkdir).toBe(true); + }).pipe( + Effect.provide( + makeLayer({ + workdirFlag: Option.some(""), + env: { SUPABASE_WORKDIR: "/env/workdir" }, + cwd: tempRoot, + }), + ), + ), + ); + + // With no env either, the same empty --workdir flag falls all the way + // through to the ancestor walk-up, which is the defaulted (non-explicit) + // path. + it.effect("an empty --workdir flag with no env falls through to the walk-up", () => + Effect.gen(function* () { + const config = yield* LegacyCliSettings; + expect(config.workdir).toBe(tempRoot); + expect(config.explicitWorkdir).toBe(false); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some(""), cwd: tempRoot }))), + ); + // Every later reader of the resolved workdir — including the // `Config.ProjectId` cwd-basename default — must see the real absolute // directory, never the raw flag/env string. A relative `--workdir @@ -456,6 +494,9 @@ describe("legacyCliSettingsLayer", () => { return Effect.gen(function* () { const config = yield* LegacyCliSettings; expect(config.workdir).toBe(projectRoot); + // The ancestor climb found a config.toml — this resolution is the + // defaulted walk-up, not an explicit --workdir/SUPABASE_WORKDIR. + expect(config.explicitWorkdir).toBe(false); }).pipe(Effect.provide(makeLayer({ cwd: nested }))); }); @@ -463,6 +504,9 @@ describe("legacyCliSettingsLayer", () => { Effect.gen(function* () { const config = yield* LegacyCliSettings; expect(config.workdir).toBe(tempRoot); + // The climb never found a config.toml and fell back to cwd unchanged — + // still the defaulted path, not an explicit workdir. + expect(config.explicitWorkdir).toBe(false); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); diff --git a/apps/cli/src/config/legacy-cli-settings.service.ts b/apps/cli/src/config/legacy-cli-settings.service.ts index 9a28eca863..cebc337e84 100644 --- a/apps/cli/src/config/legacy-cli-settings.service.ts +++ b/apps/cli/src/config/legacy-cli-settings.service.ts @@ -39,6 +39,23 @@ interface LegacyCliSettingsShape { readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; + /** + * Whether {@link workdir} came from an explicit `--workdir`/`SUPABASE_WORKDIR` + * (used exactly as given) rather than the default ancestor walk-up. True iff + * the resolution did NOT climb. + * + * Config loads that accept `supabase/config.json` must pass + * `search: legacyShouldSearchAncestors(cliSettings)` to + * `loadCliConfig`/`findCliProjectPaths`/`findCliProjectRoot` — see + * `legacyShouldSearchAncestors` (`command-internal/legacy-workdir-search.ts`) + * for the full rule and why callers that also pass `tomlOnly: true` instead + * pass `search: false` unconditionally. A load that also tolerates a `null` + * result carries a paired obligation for that rule's 4th point: hard-fail + * when `explicitWorkdir` is true unless it's one of the documented + * exceptions — see `legacyMissingProjectConfigMessage`/ + * `legacyRequireExplicitWorkdirProject` (`command-internal/legacy-workdir-project.ts`). + */ + readonly explicitWorkdir: boolean; readonly userAgent: string; } diff --git a/apps/cli/src/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/config/legacy-project-ref.layer.unit.test.ts index 53fa871137..1e1e0f4871 100644 --- a/apps/cli/src/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/legacy-project-ref.layer.unit.test.ts @@ -28,6 +28,7 @@ function mockCliSettings(opts: { workdir: string; projectId?: string }) { accessToken: Option.none(), projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), workdir: opts.workdir, + explicitWorkdir: false, userAgent: "SupabaseCLI/0.0.0-dev", }); } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 7194196da8..57857c3abc 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2372,6 +2372,12 @@ export function deployFunctions( const configFunctions = yield* inferFunctionsManifest({ cwd: dependencies.projectRoot, config: deployConfig, + // Matches `loadFunctionsCliConfig`'s own options above (`search: false, + // tomlOnly: true` for the legacy shell): no ancestor directory is + // searched past `dependencies.projectRoot` for EITHER load, so they can + // never resolve two different projects (same rationale as + // `start.handler.ts`'s equivalent call). + search: dependencies.goConfigCompat === undefined, }); const configDeclaredFunctions = deployConfig?.functions ?? {}; const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 4b0591beea..44926aa44f 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -715,7 +715,16 @@ const resolveServeConfig = Effect.fnUntraced(function* ( goViperCompat: boolean, goConfigCompat: FunctionsGoConfigCompat | undefined, ) { - const projectEnv = yield* loadServeCliProjectEnvironment(projectRoot); + // This single value is what keeps the `.env` discovery, the config load, + // and the functions-manifest inference from ever resolving three + // different roots: `goConfigCompat === undefined` (`next`/library path) + // keeps the package-default ancestor search; the legacy shell's + // `search: false` below must match `loadFunctionsCliConfig`'s own options + // exactly (see the config-load comment further down). + const searchAncestors = goConfigCompat === undefined; + const projectEnv = yield* loadServeCliProjectEnvironment(projectRoot, { + search: searchAncestors, + }); const projectRef = Option.match(projectIdOverride, { onNone: () => undefined, onSome: (value) => { @@ -728,19 +737,22 @@ const resolveServeConfig = Effect.fnUntraced(function* ( // `.env.`/`.env.local`/`.env` over the ambient env) and pass it // in, so loading neither re-reads those files nor mutates `process.env`. // - // `search: false`/`tomlOnly: true` when `goConfigCompat` is set (legacy - // shell): this MUST match `loadFunctionsCliConfig`'s own options below - // exactly, or the two loads can resolve two different files (an ancestor's - // config.toml vs this dir's; a stray config.json vs config.toml) — one - // supplying `auth`/`edgeRuntime`/`apiPort` here, the other supplying - // `denoVersion`/`Config.Validate` below, silently mixing fields from two - // different projects. `next` (`goConfigCompat === undefined`) keeps the - // package defaults (ancestor search, JSON preferred), unchanged. + // `search: searchAncestors` (`false`)/`tomlOnly: true` when `goConfigCompat` + // is set (legacy shell): this MUST match `loadFunctionsCliConfig`'s own + // options below exactly, or the two loads can resolve two different files + // (an ancestor's config.toml vs this dir's; a stray config.json vs + // config.toml) — one supplying `auth`/`edgeRuntime`/`apiPort` here, the + // other supplying `denoVersion`/`Config.Validate` below, silently mixing + // fields from two different projects. `next` (`goConfigCompat === undefined`) + // keeps the package defaults (ancestor search, JSON preferred), unchanged — + // `search: searchAncestors` is `search: true` there, identical to the + // previously-absent default. const loadedConfig = yield* loadCliConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { cliProjectEnv: projectEnv }), goViperCompat, - ...(goConfigCompat === undefined ? {} : { search: false, tomlOnly: true }), + search: searchAncestors, + ...(goConfigCompat === undefined ? {} : { tomlOnly: true }), }); const baseConfig = loadedConfig?.config ?? defaultCliConfig; @@ -777,6 +789,7 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const configFunctions = yield* inferFunctionsManifest({ cwd: projectRoot, config: configForManifest, + search: searchAncestors, }); const configProjectId = projectEnv === null @@ -1112,8 +1125,11 @@ function ambientProjectEnv() { ); } -const loadServeCliProjectEnvironment = Effect.fnUntraced(function* (projectRoot: string) { - const paths = yield* findCliProjectPaths(projectRoot); +const loadServeCliProjectEnvironment = Effect.fnUntraced(function* ( + projectRoot: string, + options: { readonly search: boolean }, +) { + const paths = yield* findCliProjectPaths(projectRoot, { search: options.search }); if (paths === null) { return null; } diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 7f51898b22..daae06aa79 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -66,7 +66,9 @@ export const LegacyDebugFlag = GlobalFlag.setting("debug")({ export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({ flag: Flag.string("workdir").pipe( - Flag.withDescription("path to a Supabase project directory"), + Flag.withDescription( + "path to the directory containing your supabase/ folder; used exactly as given, with no ancestor directory search (defaults to searching upward from the current directory)", + ), Flag.optional, ), }); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 6e4121e775..6594b1d9cd 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -401,6 +401,7 @@ export function mockLegacyLinkedProjectCacheTracked(): { export function mockLegacyCliSettings(opts: { readonly workdir: string; + readonly explicitWorkdir?: boolean; readonly profile?: string; readonly apiUrl?: string; readonly projectHost?: string; @@ -419,6 +420,7 @@ export function mockLegacyCliSettings(opts: { accessToken: opts.accessToken ?? Option.some(Redacted.make(LEGACY_VALID_TOKEN)), projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), workdir: opts.workdir, + explicitWorkdir: opts.explicitWorkdir ?? false, userAgent: opts.userAgent ?? LEGACY_DEFAULT_USER_AGENT, }); } diff --git a/apps/cli/tests/helpers/legacy-storage.ts b/apps/cli/tests/helpers/legacy-storage.ts index 6f29bd851e..51c64896f1 100644 --- a/apps/cli/tests/helpers/legacy-storage.ts +++ b/apps/cli/tests/helpers/legacy-storage.ts @@ -84,6 +84,8 @@ export interface SetupLegacyStorageOptions { }>; /** When true, `loadProjectRef` fails with `LegacyProjectNotLinkedError`. */ readonly linkedFails?: boolean; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; } /** @@ -207,7 +209,7 @@ export function setupLegacyStorage(workdir: string, opts: SetupLegacyStorageOpti httpLayer, telemetry.layer, linkedCache.layer, - mockLegacyCliSettings({ workdir }), + mockLegacyCliSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), BunServices.layer, projectRefLayer, Layer.succeed(LegacyPlatformApiFactory, { diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 26bb9bcbad..03b84a8ab0 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -312,10 +312,11 @@ export function makeWorkersProject(files: Readonly> = {}) } /** - * `LegacyCliSettings`, trimmed to what the worker commands read: the workdir they - * treat as the project, and the host their URLs are built on. + * `LegacyCliSettings` for the worker commands: the workdir they treat as the + * project, and the host their URLs are built on, plus every other field + * `LegacyCliSettingsShape` requires. */ -const legacyTestCliConfigLayer = (workdir: string) => +const legacyTestCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => Layer.succeed(LegacyCliSettings, { profile: "supabase", apiUrl: "https://api.supabase.com", @@ -325,8 +326,9 @@ const legacyTestCliConfigLayer = (workdir: string) => accessToken: Option.some(Redacted.make("sbp_test")), projectId: Option.none(), workdir, + explicitWorkdir, userAgent: "supabase", - } as unknown as LegacyCliSettings["Service"]); + }); /** The resolver, stubbed: `--project-ref` wins, else the linked project. */ const legacyTestProjectRefLayer = (linked: boolean) => @@ -345,6 +347,8 @@ const legacyTestProjectRefLayer = (linked: boolean) => export interface WorkersSetupOptions { readonly workdir: string; + /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ + readonly explicitWorkdir?: boolean; /** * The directory the command was invoked from, when it differs from the * project — which is what a relative `--source` resolves against. @@ -432,7 +436,7 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), - legacyTestCliConfigLayer(options.workdir), + legacyTestCliConfigLayer(options.workdir, options.explicitWorkdir ?? false), legacyTestProjectRefLayer(options.linked !== false), telemetry.layer, mockLegacyLinkedProjectCacheLayer, diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index 31c107307f..fb28f7fe7d 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -96,7 +96,10 @@ export const findCliProjectPaths = Effect.fnUntraced(function* ( } }); -export const findCliProjectRoot = Effect.fnUntraced(function* (cwd: string) { - const paths = yield* findCliProjectPaths(cwd); +export const findCliProjectRoot = Effect.fnUntraced(function* ( + cwd: string, + options?: FindCliProjectPathsOptions, +) { + const paths = yield* findCliProjectPaths(cwd, options); return paths?.projectRoot ?? null; }); From c2fc2584a281ffceb43d6a379dceba15a201b8c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:21:57 +0000 Subject: [PATCH 03/57] chore(cli-go): bump github.com/posthog/posthog-go from 1.24.3 to 1.24.4 in /apps/cli-go in the go-minor group across 1 directory (#6504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go-minor group with 1 update in the /apps/cli-go directory: [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go). Updates `github.com/posthog/posthog-go` from 1.24.3 to 1.24.4
Release notes

Sourced from github.com/posthog/posthog-go's releases.

1.24.4

Unreleased

Changelog

Sourced from github.com/posthog/posthog-go's changelog.

1.24.4

Patch Changes

  • c3270b6: Align local feature flag property matching with the flags service, including boolean-array precedence, canonical JSON stringification, and operator-specific case folding.
Commits
  • 0216c23 chore: release v1.24.4 [version bump] [skip ci]
  • c3270b6 fix(flags): align local exact matching with the flags service (#300)
  • d5152ea chore(deps): bump the github-actions group with 2 updates (#304)
  • 518931f chore(deps-dev): bump @​changesets/cli from 3.0.0 to 3.0.1 in the release-tool...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/posthog/posthog-go&package-manager=go_modules&previous-version=1.24.3&new-version=1.24.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/cli-go/go.mod | 2 +- apps/cli-go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 641c729020..c7690cdd80 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -35,7 +35,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.24.3 + github.com/posthog/posthog-go v1.24.4 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index cbaa31edaf..f394469ea8 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -753,8 +753,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.24.3 h1:EMWkrhXODtjFI7lUCxS4gzkNe/zaFeeS2oyS06MvfTo= -github.com/posthog/posthog-go v1.24.3/go.mod h1:seY9mmw3mYGT9i2Wr5Dn3JXWPiAkZ6aolwH/5l8eVQE= +github.com/posthog/posthog-go v1.24.4 h1:jmgsQZWqNCUqcdkGwrs/cBXhw4fRpb0rk31Mbug1SSQ= +github.com/posthog/posthog-go v1.24.4/go.mod h1:rN0C9Urenhytgo3KQ/QVorGkyGfpouquDTSs2Ujzr+g= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= From 23e6b0ea9c18ad7d1dc08caf2bc8f2e1db9aebc0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 7 Sep 2026 18:07:14 +0000 Subject: [PATCH 04/57] fix(cli): enforce content_path project-root containment for every consumer (CLI-2339) (#6498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary CLI-2320 confined `auth.email.template.*.content_path`/`auth.email.notification.*.content_path` resolution to the project root, but only inside `config push`'s own content loader. This centralizes that containment into the shared resolver `legacyResolveEmailTemplateContentPath` (`legacy-config-validate.ts`), so it now protects every consumer, with no flag and no opt-out: `config push`, `start` (an eager pre-Docker validation pass covering every configured template plus every enabled notification — the same set Kong's mount builder consumes), and the shared config-validation path reached by `db`/`migration`/`status`/`stop`/`functions deploy`/`functions serve`/`functions download`/`gen types`/`inspect`/`bootstrap`. Linear: CLI-2339 (follow-up from CLI-2320's own PR review, #6489). While extending the check's reach, two bugs surfaced and are fixed in the same change: - The canonicalization helper treated any `realpath` failure as "this path doesn't exist yet" and fell back to lexical resolution — which also covers a dangling symlink, an `EACCES`-blocked target, or a symlink loop, all of which exist on disk but couldn't be canonicalized. That let an in-root symlink pointing outside the project root bypass containment silently, most seriously for `start`'s Kong mount (a root-privileged, `rw` Docker bind mount). Fixed by distinguishing "genuinely absent" from "exists but uncanonicalizable" and following a symlink to its real target before checking it. The ancestor walk was also rewritten iteratively to remove a stack-depth limit on deeply nested missing paths. - `start`'s Kong mount resolved and validated a path early, then independently re-derived and used a second, unresolved path much later when building the Docker bind mount — a check/use gap and duplicated resolution logic. The validated, read-verified path is now threaded straight through to the bind-mount builder instead of being re-derived. The rejection message now includes the declared `content_path` value and the project root (not the fully symlink-dereferenced target, to avoid echoing back where an escaping symlink actually points). ## What changed - `legacy-config-validate.ts` — `legacyResolveEmailTemplateContentPath` now canonicalizes and containment-checks its result before returning; new `canonicalPathForContainment`/ `canonicalizeExistingPath`/`isPathContainedInRoot` helpers. - `push.auth-email-content.ts` — deleted its local, now-redundant containment helpers; both template and notification loading route through the shared resolver. - `start.handler.ts`/`kong.service.ts` — `resolveKongEmailTemplateMounts` resolves, containment- checks, and read-verifies every Kong-mounted template/notification once, early, before any Docker work; `LegacyKongEmailTemplateMount` carries the resolved path, and `legacyBuildKongEmailTemplateBind` is now a pure formatter with no resolution logic of its own. - `SIDE_EFFECTS.md` updates across `start`, `status`, `stop`, `db diff`, `migration squash`, `functions deploy/serve/download`, and one line in `apps/cli/AGENTS.md`'s "config validation has one home" section. Follow-ups filed for the adjacent untrusted-path fields this ticket didn't touch (CLI-2344), and a message-polish gap where an `EACCES` behind a followed symlink surfaces a raw filesystem error instead of the usual containment message (CLI-2345) — in both cases the path is still rejected, just with a less specific error. --- apps/cli/AGENTS.md | 2 +- ...ig-validate.deep-missing-path.unit.test.ts | 56 ++++ ...legacy-config-validate.parity.unit.test.ts | 18 ++ .../legacy-config-validate.ts | 136 +++++++++- .../legacy-config-validate.unit.test.ts | 256 +++++++++++++++++- .../legacy-local-config-values.ts | 2 +- .../legacy-local-config-values.unit.test.ts | 17 ++ .../src/commands/config/push/SIDE_EFFECTS.md | 2 +- .../config/push/push.auth-email-content.ts | 114 +++----- .../push/push.auth-email-content.unit.test.ts | 96 ++++++- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 25 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 2 +- .../functions/download/SIDE_EFFECTS.md | 2 +- .../commands/functions/serve/SIDE_EFFECTS.md | 26 +- .../commands/migration/squash/SIDE_EFFECTS.md | 21 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 7 +- .../commands/start/services/kong.service.ts | 65 +++-- .../start/services/kong.service.unit.test.ts | 74 ++--- apps/cli/src/commands/start/start.handler.ts | 105 +++++-- .../commands/start/start.integration.test.ts | 64 +++++ apps/cli/src/commands/status/SIDE_EFFECTS.md | 15 +- apps/cli/src/commands/stop/SIDE_EFFECTS.md | 18 +- 22 files changed, 860 insertions(+), 263 deletions(-) create mode 100644 apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b675cbde9e..b65c1a3cae 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -251,7 +251,7 @@ This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsol ### Config validation has one home -Config validation is implemented exactly once: `src/command-internal/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. +Config validation is implemented exactly once: `src/command-internal/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. `content_path` project-root containment (absolute paths, `..` escapes, and in-root symlinks pointing outside all rejected) is part of this same home, enforced inside `legacyResolveEmailTemplateContentPath` — any new consumer of `content_path` resolution gets containment for free and must not re-derive it locally. --- diff --git a/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts new file mode 100644 index 0000000000..1cb1347584 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts @@ -0,0 +1,56 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +/** + * Regression coverage for the iterative (not recursive) ancestor walk-up rewrite in + * `canonicalPathForContainment` (CLI-2339's symlink-containment hardening pass) — a security + * review found the earlier, fully-recursive walk-up blew the JS call stack around ~20,000 missing + * path components. + * + * A REAL filesystem cannot actually construct a path this deep: every real syscall + * (`realpathSync`/`lstatSync`) enforces the OS's own `PATH_MAX` (~1024 bytes on macOS, ~4096 on + * Linux), which caps a real missing-component chain at a few hundred to low thousands of + * components — nowhere near the 5,000 needed to meaningfully exercise (and rule out a stack-depth + * regression in) the walk-up loop itself. This file mocks `node:fs` at the filesystem boundary + * instead — the sanctioned seam for this kind of test per this workspace's testing conventions — + * so the loop's OWN iteration count is what's under test, not the host OS's path-length limit. + * Isolated into its own file (rather than folded into `legacy-config-validate.unit.test.ts`) + * because `vi.mock` is file-scoped: every other test in that file relies on a REAL filesystem + * (real symlinks, real dangling/looping targets), which a module-wide `node:fs` mock would break. + */ +vi.mock("node:fs", () => ({ + realpathSync: vi.fn((path: string) => { + if (path === FAKE_EXISTING_BASE) return FAKE_EXISTING_BASE; + throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { + code: "ENOENT", + }); + }), + lstatSync: vi.fn(() => undefined), + readlinkSync: vi.fn(() => { + throw new Error("readlinkSync should never be reached — no path in this fixture is a symlink"); + }), + statSync: vi.fn(() => { + throw new Error("statSync should never be reached by the template content_path branch"); + }), +})); + +const FAKE_EXISTING_BASE = "/fake/project-root"; + +describe("canonicalPathForContainment (via legacyResolveEmailTemplateContentPath)", () => { + it("resolves a 5,000-component-deep missing content_path without blowing the call stack", async () => { + const { legacyResolveEmailTemplateContentPath } = await import("./legacy-config-validate.ts"); + + const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); + const contentPath = `${missingSegments.join("/")}/invite.html`; + + const resolved = legacyResolveEmailTemplateContentPath({ + section: "template", + name: "invite", + contentPath, + contentPresent: false, + base: FAKE_EXISTING_BASE, + }); + + expect(resolved).toBe(join(FAKE_EXISTING_BASE, contentPath)); + }); +}); diff --git a/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts index dcad4fd0dd..641f57b7e1 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts @@ -102,6 +102,24 @@ const scenarios: ReadonlyArray = [ }, message: "Invalid config for auth.email.notification.password_changed.content_path", }, + { + name: "auth.email.notification content_path resolves outside the project root", + toml: [ + "[auth.email.notification.password_changed]", + "enabled = true", + 'content_path = "/etc/hosts"', + ], + overrides: { + auth: { + email: { + notification: { + password_changed: { enabled: true, content_path: "/etc/hosts" }, + }, + }, + }, + }, + message: "resolves outside the project root", + }, { name: "db.port = 0", toml: ["[db]", "port = 0"], diff --git a/apps/cli/src/command-internal/legacy-config-validate.ts b/apps/cli/src/command-internal/legacy-config-validate.ts index a6399b1cde..aaca5ce204 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.ts @@ -1,5 +1,5 @@ -import { statSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { actionability, @@ -757,13 +757,113 @@ export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { // ── email template / notification ── +/** + * Whether `candidatePath` resolves inside (or exactly to) `root`. Both + * arguments must already be canonicalized (see `canonicalPathForContainment`). + * Only rejects a genuine `..` traversal — a same-level sibling whose name + * happens to start with two dots (e.g. `..templates`) is a distinct, + * in-root path and must not be rejected. + */ +function isPathContainedInRoot(root: string, candidatePath: string): boolean { + const rel = relative(root, candidatePath); + return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); +} + +// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when +// manually following a dangling/unsearchable/looping symlink one hop at a +// time (see `canonicalizeExistingPath` below), so that manual follow needs +// its own explicit bound. +const MAX_SYMLINK_FOLLOW_DEPTH = 40; + +/** + * Canonicalizes `path` when it exists, or returns `undefined` when it + * genuinely doesn't — the signal {@link canonicalPathForContainment} needs + * to decide whether to keep walking up towards an existing ancestor. + * + * "Exists" is decided with `lstatSync` (which doesn't itself dereference + * `path`), not by whether `realpathSync` succeeded — a dangling symlink, a + * symlink whose target directory is unsearchable (`EACCES`), or a symlink + * loop (`ELOOP`) all make `realpathSync` throw even though `path` genuinely + * exists on disk. Such a symlink is followed one hop by hand + * (`readlinkSync`) and its target canonicalized in turn, so the containment + * check always sees where the symlink actually points rather than a lexical + * guess that ignores it. Anything else existing-but-uncanonicalizable (or a + * symlink chain past {@link MAX_SYMLINK_FOLLOW_DEPTH}) is returned as-is — + * refusing to vouch for it lexically; the containment check still compares + * it honestly, and any subsequent read fails with its own real error. + */ +function canonicalizeExistingPath(path: string, depth: number): string | undefined { + try { + return realpathSync(path); + } catch { + const entry = lstatSync(path, { throwIfNoEntry: false }); + if (entry === undefined) return undefined; + if (entry.isSymbolicLink() && depth < MAX_SYMLINK_FOLLOW_DEPTH) { + const target = readlinkSync(path); + return canonicalPathForContainment( + isAbsolute(target) ? target : join(dirname(path), target), + depth + 1, + ); + } + return path; + } +} + +/** + * Canonicalizes `path` for the containment check, tolerating a path (or an + * ancestor of it) that genuinely doesn't exist yet — that's the normal case + * for a missing template file, which should surface as a missing-file + * error, not a containment error. Walks up to the deepest EXISTING + * ancestor, resolves that with `realpathSync` (dereferencing any symlinks + * in it — including a symlinked project root itself, e.g. macOS's `/tmp` + * -> `/private/tmp`), then re-appends the missing tail lexically. The + * walk-up is an iterative loop, not recursion, so it stays correct against + * a pathologically long chain of missing ancestors (a stack-depth overflow + * was observed around 20,000 components with a naive recursive walk); each + * ancestor is still checked via {@link canonicalizeExistingPath}, so an + * intermediate dangling/unsearchable/looping symlink is followed rather + * than lexically skipped over as if it were an ordinary missing directory. + * + * A dangling, unsearchable, or looping symlink is never laundered as a + * missing tail component — see {@link canonicalizeExistingPath} for how + * "exists but can't canonicalize" is told apart from "doesn't exist" and + * followed to its real target. Recurses (bounded by `depth`) only to follow + * that kind of symlink; the ancestor walk-up itself is iterative. + */ +function canonicalPathForContainment(path: string, depth = 0): string { + const canonical = canonicalizeExistingPath(path, depth); + if (canonical !== undefined) return canonical; + + const tail: string[] = [basename(path)]; + let current = dirname(path); + for (;;) { + const ancestorCanonical = canonicalizeExistingPath(current, depth); + if (ancestorCanonical !== undefined) { + return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical); + } + const parent = dirname(current); + if (parent === current) { + return resolve(tail.reduceRight((acc, name) => join(acc, name), current)); + } + tail.push(basename(current)); + current = parent; + } +} + /** * Pure exclusivity decision + path to read for one template/notification entry. Throws * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and - * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to - * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even - * when `content` is ALSO set) always wins — "both set" is not rejected, `content_path` - * silently wins/overwrites. + * `contentPresent`. Returns the absolute, canonicalized (symlink-dereferenced) path to read, or + * `undefined` when there's nothing to read (both `contentPath` and `content` absent — skip, not + * an error). `contentPath` set (even when `content` is ALSO set) always wins — "both set" is not + * rejected, `content_path` silently wins/overwrites. + * + * The resolved candidate and `base` are both canonicalized (`canonicalPathForContainment`) and + * the candidate must resolve inside `base` (`isPathContainedInRoot`) — an absolute path, a `..` + * escape, or an in-root symlink pointing outside the project root all throw, since every caller + * reads or uploads the returned path's bytes. This applies unconditionally to every caller of + * this function (config validation, `config push` content loading, `start`'s eager pre-Docker + * containment pass) — there is no flag or opt-out. * * `base` is the caller-resolved project root for both templates and notifications. */ @@ -784,10 +884,28 @@ export function legacyResolveEmailTemplateContentPath(args: { } return undefined; } - if (args.section === "notification") { - return legacyResolveNotificationContentPath(args.base, args.contentPath); + const candidate = + args.section === "notification" + ? legacyResolveNotificationContentPath(args.base, args.contentPath) + : isAbsolute(args.contentPath) + ? args.contentPath + : join(args.base, args.contentPath); + const resolvedCanonical = canonicalPathForContainment(candidate); + const canonicalBase = canonicalPathForContainment(args.base); + if (!isPathContainedInRoot(canonicalBase, resolvedCanonical)) { + // Echo the DECLARED value (`args.contentPath`), not `resolvedCanonical` — + // the declared value is either what's literally in config.toml or an + // env-var override the caller already resolved, both already known to + // the user; the fully symlink-dereferenced canonical target is not, and + // echoing it back would hand a hostile config a way to probe what an + // in-root symlink resolves to on the runner (weak recon, but needless). + throw new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content_path: ` + + `"${args.contentPath}" resolves outside the project root ${args.base} — ` + + `move the file inside the project, or use a relative path that stays inside it.`, + ); } - return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); + return resolvedCanonical; } /** diff --git a/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts index f613da095c..f9df11a8b1 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { LEGACY_BUCKET_NAME_PATTERN, @@ -6,9 +18,11 @@ import { LEGACY_FUNCTION_SLUG_PATTERN, LEGACY_HOOK_SECRET_PATTERN, LEGACY_PROJECT_REF_PATTERN, + LegacyConfigValidateError, type LegacyAuthInput, type LegacyConfigValidationInput, legacyParseGoBool, + legacyResolveEmailTemplateContentPath, legacyValidateResolvedConfig, } from "./legacy-config-validate.ts"; @@ -96,6 +110,246 @@ describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { }); }); +// Direct coverage for the containment behavior CLI-2339 centralized into this function — every +// caller (`config push`'s `legacyLoadAuthEmailContent`, `legacy-db-config.toml-read.ts`, +// `legacy-local-config-values.ts`, `start.handler.ts`'s eager pre-Docker pass) now shares it, so +// pinning it here directly is cheaper than re-deriving it through every caller's own fixtures. +// `push.auth-email-content.unit.test.ts` keeps its own equivalent coverage through +// `legacyLoadAuthEmailContent` (CLI-2320's original suite, still exercising the same behavior +// through a real caller); this block is the new, function-level home CLI-2339 introduces. +describe("legacyResolveEmailTemplateContentPath", () => { + let projectRoot = ""; + let outsideDir = ""; + + afterEach(() => { + if (projectRoot.length > 0) { + rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = ""; + } + if (outsideDir.length > 0) { + rmSync(outsideDir, { recursive: true, force: true }); + outsideDir = ""; + } + }); + + function setup(): string { + projectRoot = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-")); + return projectRoot; + } + + /** A real file outside `base`, so a containment test proves the escape check fires rather than a missing-file error. */ + function setupOutsideFile(): string { + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const outsideFile = join(outsideDir, "secret.html"); + writeFileSync(outsideFile, "

Outside

"); + return outsideFile; + } + + function resolveContentPath( + section: "template" | "notification", + contentPath: string, + base: string, + ) { + return legacyResolveEmailTemplateContentPath({ + section, + name: "invite", + contentPath, + contentPresent: false, + base, + }); + } + + it.each(["template", "notification"] as const)( + "rejects an absolute %s content_path outside the project root", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + + expect(() => resolveContentPath(section, outsideFile, base)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath(section, outsideFile, base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it.each(["template", "notification"] as const)( + "rejects a relative %s content_path that escapes the project root via ..", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + const escapePath = relative(base, outsideFile); + + expect(() => resolveContentPath(section, escapePath, base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root symlink pointing outside the project root", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + const symlinkPath = join(base, "evil.html"); + symlinkSync(outsideFile, symlinkPath); + + expect(() => resolveContentPath(section, "./evil.html", base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it("accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", () => { + // This is the exact boundary `isPathContainedInRoot`'s `rel !== ".." && + // !rel.startsWith(".." + sep)` check exists to draw: `..templates` is a real, distinct + // directory name one level under the root — not a `..` parent-traversal segment — and must + // resolve normally. + const base = setup(); + const dotDir = join(base, "..templates"); + mkdirSync(dotDir, { recursive: true }); + writeFileSync(join(dotDir, "invite.html"), "

Invite

"); + + const resolved = resolveContentPath("template", "..templates/invite.html", base); + + expect(resolved).toBe(join(realpathSync(base), "..templates", "invite.html")); + }); + + it("accepts a content_path that resolves to exactly the project root", () => { + const base = setup(); + + const resolved = resolveContentPath("template", ".", base); + + expect(resolved).toBe(realpathSync(base)); + }); + + it("resolves a missing in-root file behind a symlinked project root instead of raising the containment error", () => { + // The CLI-2339 fix: `canonicalPathForContainment` walks up to the deepest EXISTING + // ancestor and canonicalizes THAT, then lexically re-appends the missing leaf — so a + // project root reached through a symlink (`symlinkedRoot` here) still canonicalizes to the + // same base as the candidate. CLI-2320's original `realOrLexicalPath` fell back to a fully + // LEXICAL `resolve(candidate)` the moment the leaf was missing, while `root` itself was + // always realpath'd unconditionally — comparing a resolved root against an unresolved + // candidate through the symlink would have reported a false "resolves outside the project + // root" for this exact case. + const realDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + const resolved = resolveContentPath("template", "missing-invite.html", symlinkedRoot); + expect(resolved).toBe(join(realpathSync(symlinkedRoot), "missing-invite.html")); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } + }); + + // Direct regression coverage for the CLI-2339 follow-up fix: `canonicalPathForContainment` + // now tells apart "this path component genuinely doesn't exist yet" from "this path exists but + // couldn't be canonicalized" (a dangling symlink, an EACCES-blocked target, or a symlink loop). + // Before this fix, ALL THREE were wrongly treated as "doesn't exist" — meaning a dangling/broken + // in-root symlink pointing outside the project root was silently ACCEPTED as in-root instead of + // rejected (verified exploitable via `start`'s Kong `rw` Docker bind mount). Only the dangling + // case above is deterministic on every OS/CI environment without special permissions; the other + // two are covered per their own comments below. + it("rejects a content_path that is an in-root dangling symlink pointing to a nonexistent target outside the project root", () => { + // The core regression case: `lstatSync` shows the symlink itself genuinely exists, but its + // target does not — before the fix, that combination was wrongly folded into "doesn't exist" + // (the same bucket as a plain missing file), silently laundering the escape as an ordinary + // missing-file resolution instead of rejecting it. + const base = setup(); + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const neverCreatedOutsideTarget = join(outsideDir, "never-created.html"); + const danglingSymlinkPath = join(base, "dangling.html"); + symlinkSync(neverCreatedOutsideTarget, danglingSymlinkPath); + + expect(() => resolveContentPath("template", "./dangling.html", base)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath("template", "./dangling.html", base)).toThrow( + /resolves outside the project root/, + ); + }); + + it("rejects a content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", () => { + // A target one level inside a chmod-000 directory makes BOTH `realpathSync` and (per + // POSIX pathname resolution, since finding the target's own dirent also needs search + // permission on its parent) `lstatSync` fail with EACCES, not ENOENT — this must never be + // laundered into "doesn't exist" either. This still fails closed (never returns a path + // silently treated as in-root) in every environment this was verified against, including as + // an unprivileged, non-root user (the only case that actually exercises the EACCES branch — + // as root, chmod 000 is a no-op and the target resolves normally, hitting the ordinary + // out-of-root rejection instead). Skip only if this environment doesn't enforce the + // permission at all (e.g. running as root) — the deterministic dangling-symlink case above + // already covers the core regression without needing any permission trick. + const base = setup(); + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const unsearchableDir = join(outsideDir, "locked"); + mkdirSync(unsearchableDir); + const target = join(unsearchableDir, "secret.html"); + writeFileSync(target, "

Locked

"); + chmodSync(unsearchableDir, 0o000); + + try { + let permissionEnforced = true; + try { + readdirSync(unsearchableDir); + permissionEnforced = false; + } catch { + // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. + } + if (!permissionEnforced) { + return; + } + + const symlinkPath = join(base, "unsearchable.html"); + symlinkSync(target, symlinkPath); + + // Never silently accepted as in-root: it must fail closed, one way or another. + expect(() => resolveContentPath("template", "./unsearchable.html", base)).toThrow(); + } finally { + chmodSync(unsearchableDir, 0o755); + } + }); + + it("rejects an in-root symlink loop instead of hanging or crashing", () => { + // `canonicalPathForContainment` cannot canonicalize a genuine cycle at all — past + // `MAX_SYMLINK_FOLLOW_DEPTH` hops it gives up and returns the (lexical, never + // realpath-dereferenced) path as-is, per its own contract. Containment then compares that + // unverified lexical path against the fully-canonicalized project root. A project root + // reached through no symlink of its own could coincidentally still compare equal (since + // there's nothing to dereference), so this deliberately reuses the same symlinked-root + // fixture as the "missing leaf behind a symlinked project root" test above — guaranteeing + // a real canonicalization gap between the root and the un-canonicalizable loop path, + // deterministically on every OS, rather than depending on incidental symlinks somewhere in + // the ambient tmpdir (e.g. macOS's own /tmp -> /private/tmp). + const realDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + const loopA = join(symlinkedRoot, "loop-a.html"); + const loopB = join(symlinkedRoot, "loop-b.html"); + symlinkSync(loopB, loopA); + symlinkSync(loopA, loopB); + + expect(() => resolveContentPath("template", "./loop-a.html", symlinkedRoot)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath("template", "./loop-a.html", symlinkedRoot)).toThrow( + /resolves outside the project root/, + ); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } + }); +}); + /** * A trivially-passing full input. Every test below spreads/overrides only the field(s) its * check cares about, matching the fixture-building style of `legacy-local-config-values.unit. diff --git a/apps/cli/src/command-internal/legacy-local-config-values.ts b/apps/cli/src/command-internal/legacy-local-config-values.ts index 1a11189848..2dab9d896b 100644 --- a/apps/cli/src/command-internal/legacy-local-config-values.ts +++ b/apps/cli/src/command-internal/legacy-local-config-values.ts @@ -1064,7 +1064,7 @@ export type LegacyResolvedAuthEmail = Omit< * `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp` presence gaps elsewhere in this * file. An env override always wins outright when set, regardless of the raw document. * - * This resolves the SAME effective value for both `buildKongEmailTemplateMounts` and + * This resolves the SAME effective value for both `resolveKongEmailTemplateMounts` and * `resolveGotrueEnvInput` in `start.handler.ts`, which both need the post-override email config. */ export function legacyResolveAuthEmail( diff --git a/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts b/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts index 27c30daf66..3c8971f973 100644 --- a/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts @@ -2670,6 +2670,23 @@ describe("legacyResolveLocalConfigValues", () => { ); }); + it("rejects an absolute template content_path outside the project root", () => { + // Proves the shared validator (`legacyResolveEmailTemplateContentPath` in + // `legacy-config-validate.ts`) now enforces project-root containment on the + // `db`/`migration`/`status`/`stop`/... shared-validation path too, not only inside + // `config push`'s own content loader — CLI-2339's centralization. + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "/etc/hosts" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', + ); + }); + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { writeFileSync(join(tempRoot.current, "invite.html"), ""); const config = baseConfig({ diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 8abf97dc27..d958725908 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -354,7 +354,7 @@ may itself contain a `.`. - **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). - The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. -- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. +- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. This containment check now lives centrally in `legacyResolveEmailTemplateContentPath` (`legacy-config-validate.ts`) rather than in this command's own module (CLI-2339), and applies unconditionally to every caller of that resolver — config validation and `start`'s eager pre-Docker pass, not just `config push`. - **Only properties your file declares, and whose value differs from the project, are written.** Fields the API requires together ship as a group; undeclared members of that group are sent with the project's CURRENT value, read in the same run — so they do not change. Only when the read did not return a member's current value is it sent at the config schema default, and that is always disclosed (a `[group-write]` block in the confirmation output, a `forced` entry in the JSON payload, and a summary `Note:` line) — never applied silently. - **`db.ssl_enforcement`'s presence, not its decoded default, decides the gate.** `@supabase/config`'s projection recovers whether `[db.ssl_enforcement]` (and `storage.image_transformation`/`storage.s3_protocol`) were actually declared, as opposed to decoding to a schema default; an undeclared `[db.ssl_enforcement]` is treated as `disabled` — no read is needed for this any more, since row 1's single response already carries the remote value. - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) follow that same presence rule end to end — declared-but-absent is never confused with explicitly-disabled. diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.ts b/apps/cli/src/commands/config/push/push.auth-email-content.ts index 9663a206cf..67cf509cc0 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.ts @@ -3,16 +3,19 @@ * body. Both templates and notifications resolve relative paths from the * project root (parent of `supabase/`); notifications additionally fall back * to the legacy `supabase/`-relative location when the root-resolved file is - * missing, so configs written for older scaffolds keep working. Every - * resolved path — relative or absolute — is confined to the project root - * before it is read, since the loaded bytes are uploaded to whichever - * project the config names. + * missing, so configs written for older scaffolds keep working. Containment + * — confining the resolved path to the project root before it is read, since + * the loaded bytes are uploaded to whichever project the config names — is + * enforced centrally by `legacyResolveEmailTemplateContentPath` in + * `legacy-config-validate.ts`, not locally in this module. */ import type { CliConfig } from "@supabase/config"; -import { legacyResolveNotificationContentPath } from "../../../command-internal/legacy-config-validate.ts"; -import { readFileSync, realpathSync } from "node:fs"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + legacyEmailContentPathReadErrorMessage, + legacyResolveEmailTemplateContentPath, +} from "../../../command-internal/legacy-config-validate.ts"; +import { readFileSync } from "node:fs"; type AuthEmail = CliConfig["auth"]["email"]; @@ -32,74 +35,8 @@ const EMPTY_AUTH_EMAIL_CONTENT: LegacyAuthEmailContent = { }; /** - * Whether `candidatePath` resolves inside (or exactly to) `root`. Both - * arguments must already be normalized absolute paths (see `resolve`/ - * `realpathSync`). Only rejects a genuine `..` traversal — a same-level - * sibling whose name happens to start with two dots (e.g. `..templates`) - * is a distinct, in-root path and must not be rejected. - */ -function isPathContainedInRoot(root: string, candidatePath: string): boolean { - const rel = relative(root, candidatePath); - return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); -} - -/** - * Resolves `path` to its real, symlink-free location for the containment - * check, falling back to lexical normalization when the target doesn't - * exist yet — that case has no symlink to dereference, and is left for - * `readTemplateContent` to report as a normal missing-file error. - */ -function realOrLexicalPath(path: string): string { - try { - return realpathSync(path); - } catch { - return resolve(path); - } -} - -/** - * Resolves a template/notification `content_path`, rejecting any result - * that escapes the project root — a relative `..` traversal, or an absolute - * or symlinked path pointing elsewhere on disk. Rejecting here means an - * out-of-root path is never read, since the caller only reads a path this - * function returns. Symlinks are dereferenced (`realpathSync`) before the - * containment check, since `readFileSync` would otherwise follow an - * in-root symlink straight to an out-of-root target. - * - * @param kind - `template` or `notification` (used in the error prefix and to - * select the notification-only legacy `supabase/`-relative fallback). - * @param name - Config key (e.g. `invite`, `password_changed`). - * @param cwd - Discovered project root (parent of `supabase/`). - * @param contentPath - Raw `content_path` value from the config. - * @returns Absolute, symlink-resolved path, confined to `cwd`. - * @throws When the resolved path falls outside the project root. - */ -function resolveContainedContentPath( - kind: "template" | "notification", - name: string, - cwd: string, - contentPath: string, -): string { - const candidate = - kind === "notification" - ? legacyResolveNotificationContentPath(cwd, contentPath) - : isAbsolute(contentPath) - ? contentPath - : join(cwd, contentPath); - const root = realpathSync(cwd); - const resolved = realOrLexicalPath(candidate); - if (!isPathContainedInRoot(root, resolved)) { - throw new Error( - `Invalid config for auth.email.${kind}.${name}.content_path: resolves outside the project root (${resolved})`, - ); - } - return resolved; -} - -/** - * Reads a template HTML file, wrapping a filesystem error with an - * `Invalid config for auth.email...content_path: ` - * message — the CLI's established config-validation error shape. + * Reads a template HTML file, wrapping a filesystem error with the CLI's + * established config-validation error shape. * * @param kind - `template` or `notification` (used in the error prefix). * @param name - Config key (e.g. `invite`, `password_changed`). @@ -115,8 +52,7 @@ function readTemplateContent( try { return readFileSync(resolvedPath, "utf8"); } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - throw new Error(`Invalid config for auth.email.${kind}.${name}.content_path: ${message}`); + throw new Error(legacyEmailContentPathReadErrorMessage(kind, name, cause)); } } @@ -141,7 +77,17 @@ export function legacyLoadAuthEmailContent(cwd: string, email: AuthEmail): Legac if (contentPath.length === 0) { continue; } - const resolved = resolveContainedContentPath("template", name, cwd, contentPath); + const resolved = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath, + // Already checked contentPath.length > 0 above, so this can never fire. + contentPresent: false, + base: cwd, + }); + if (resolved === undefined) { + continue; + } template[name] = readTemplateContent("template", name, resolved); } @@ -153,7 +99,17 @@ export function legacyLoadAuthEmailContent(cwd: string, email: AuthEmail): Legac if (contentPath.length === 0) { continue; } - const resolved = resolveContainedContentPath("notification", name, cwd, contentPath); + const resolved = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath, + // Already checked contentPath.length > 0 above, so this can never fire. + contentPresent: false, + base: cwd, + }); + if (resolved === undefined) { + continue; + } notification[name] = readTemplateContent("notification", name, resolved); } diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts index 499a736f74..5909ca78d3 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts @@ -9,6 +9,19 @@ import { afterEach, describe, expect, it } from "vitest"; import { legacyLoadAuthEmailContent } from "./push.auth-email-content.ts"; +/** + * Builds the exact anchored containment-rejection regex for a given declared `content_path` — + * the thrown message echoes that DECLARED value (quoted) between the field name and "resolves + * outside the project root", not the fully-canonicalized target (a deliberate recon-leak + * mitigation — see `legacyResolveEmailTemplateContentPath`'s own doc comment). + */ +function containmentRejectionPattern(fieldPath: string, declaredContentPath: string): RegExp { + const escaped = declaredContentPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `^Invalid config for ${fieldPath}: "${escaped}" resolves outside the project root`, + ); +} + const emptyEmail = { enable_signup: true, double_confirm_changes: true, @@ -180,7 +193,8 @@ describe("legacyLoadAuthEmailContent", () => { it("throws a descriptive error when a template file is missing", () => { const { cwd } = setup(); - expect(() => + let thrown: unknown; + try { legacyLoadAuthEmailContent(cwd, { ...emptyEmail, template: { @@ -189,8 +203,56 @@ describe("legacyLoadAuthEmailContent", () => { content_path: "./templates/missing.html", }, }, - }), - ).toThrow(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + // A genuinely missing in-root file must surface the normal read-failure message, never the + // containment message — locks in that the symlinked-ancestor containment fix (see the + // dedicated symlink test below) doesn't regress into over-rejecting a legitimate missing + // file as "outside the project root". + expect(message).not.toMatch(/resolves outside the project root/); + expect(message).toMatch(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + }); + + it("does not raise the containment error for a template file missing behind a symlinked project root", () => { + // The project root itself is reached through a symlink (mirroring macOS's `/tmp` -> + // `/private/tmp`), and the configured template file doesn't exist. Before the CLI-2339 fix + // to `canonicalPathForContainment`, comparing a realpath'd root against a lexically-resolved + // (symlink-unaware) candidate would have misreported this as escaping the project root + // instead of a plain missing file. + const realDir = mkdtempSync(join(tmpdir(), "auth-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "auth-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + let thrown: unknown; + try { + legacyLoadAuthEmailContent(symlinkedRoot, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "./missing-invite.html", + }, + }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).not.toMatch(/resolves outside the project root/); + expect(message).toMatch(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } }); it("rejects an absolute template content_path outside the project root", () => { @@ -207,9 +269,7 @@ describe("legacyLoadAuthEmailContent", () => { }, }, }), - ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, - ); + ).toThrow(containmentRejectionPattern("auth.email.template.invite.content_path", outsideFile)); }); it("rejects an absolute notification content_path outside the project root", () => { @@ -228,7 +288,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + outsideFile, + ), ); }); @@ -247,9 +310,7 @@ describe("legacyLoadAuthEmailContent", () => { }, }, }), - ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, - ); + ).toThrow(containmentRejectionPattern("auth.email.template.invite.content_path", escapePath)); }); it("rejects a relative notification content_path that escapes the project root via ..", () => { @@ -269,7 +330,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + escapePath, + ), ); }); @@ -290,7 +354,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.template.invite.content_path", + "./evil-template.html", + ), ); }); @@ -312,7 +379,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + "./evil-notification.html", + ), ); }); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 6daa7e37ea..90f66dc042 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -17,18 +17,19 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | -| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | -| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `legacyReadDbToml`/`legacyCheckDbToml` `Config.Validate` pipeline shared by every `db`/`migration` subcommand that loads config (`db dump`/`pull`/`reset`/`push`/`schema declarative generate`/`sync`, `migration up`/`down`/`squash` — documented once here rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | +| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | +| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 7b6c6b3c87..e1e03d7822 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past `` — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/functions//index.ts` | TypeScript | function source to deploy | | `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | | imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 79e594625c..460aa3b8a3 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -11,7 +11,7 @@ | `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadCliConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadCliConfig` callers. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 24740e79d0..2a6fb4000c 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -2,19 +2,19 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | ## Files Written diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 3a154d1c8a..b6c868dc2b 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -9,16 +9,17 @@ migration-history table to match. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | -| `/supabase/migrations/` | directory | always | -| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | -| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | -| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | -| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | -| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `legacyReadDbToml` `Config.Validate` pipeline shared by `migration up`/`down` and every `db` subcommand that loads config (`dump`/`pull`/`reset`/`diff`/`push`/`schema declarative generate`/`sync` — documented on `db diff`'s `SIDE_EFFECTS.md` and here, rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/migrations/` | directory | always | +| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | +| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | +| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | +| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 47fb808344..2d58a54bfd 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -82,7 +82,7 @@ command. | project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | | `auth.signing_keys_path` file | JSON | when configured | | `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | -| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured — for every configured template plus every ENABLED notification, resolved paths are CONFINED to the project root (symlinks dereferenced with `realpathSync`) and read-verified (bytes discarded) in an eager pre-Docker pass, REGARDLESS of `auth.enabled` (Kong mounts these unconditionally — see Notes) | | GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | | `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | @@ -199,7 +199,7 @@ code is surfaced on failure. | `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | -| `1` | malformed `config.toml` / `Config.Validate` failure | +| `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | | `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | | `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | @@ -347,7 +347,8 @@ prose, not structured data. (nothing under its own directory anymore); it still matters for a removed Edge Runtime container, whose own env-file/multiline-env-script staging is unaffected by that change. -- Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. +- Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. This is NOT the same permissiveness as email template `content_path` below — a `scopes` value is an explicit opt-in mechanism, mounted `:ro`, with containment enforced on the upload side elsewhere (`functions deploy`), whereas `content_path` has no opt-in at all and is enforced at resolution time, mounted `:rw`. +- **Auth email `content_path` project-root containment AND readability apply here regardless of `auth.enabled`.** Kong's mount set (`resolveKongEmailTemplateMounts`, `start.handler.ts`) covers every configured `[auth.email.template.*]` entry, and every `enabled = true` `[auth.email.notification.*]` entry, unconditionally — Kong is the stack's mandatory gateway, independent of whether GoTrue itself is started. `start.handler.ts` resolves, confines (`legacyResolveEmailTemplateContentPath`; symlinks dereferenced with `realpathSync` before the check), AND read-verifies (a discarded `readFileSync`) that same set in one eager pass before any Docker work, in addition to the `auth.enabled`-gated read `legacyResolveLocalConfigValues`'s own validation performs; the resulting resolved path is threaded straight into Kong's bind mount (`legacyBuildKongEmailTemplateBind` in `kong.service.ts`, which no longer re-resolves anything) rather than re-derived later, right before Kong's own `docker create`. A path that resolves outside the project root aborts the run with `Invalid config for auth.email.
..content_path: resolves outside the project root ()`; a resolved, in-root path that cannot be read as a regular file aborts with `Invalid config for auth.email.
..content_path: ` — both before a single container is created, and both regardless of `auth.enabled` (closing the gap where a missing `content_path` would otherwise reach an unconditional Kong bind mount and the root-privileged Docker daemon would silently create a directory there). - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database initialization, and `start` reports the existing not-running status instead. diff --git a/apps/cli/src/commands/start/services/kong.service.ts b/apps/cli/src/commands/start/services/kong.service.ts index 8d3b72104b..a845dac3cb 100644 --- a/apps/cli/src/commands/start/services/kong.service.ts +++ b/apps/cli/src/commands/start/services/kong.service.ts @@ -50,7 +50,6 @@ */ import * as nodePath from "node:path"; -import { legacyResolveNotificationContentPath } from "../../../command-internal/legacy-config-validate.ts"; import type { LegacyStartContainerSpec } from "../../../command-internal/db-bootstrap/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; @@ -135,41 +134,44 @@ export interface LegacyKongEmailTemplateMount { * per-mount path. */ readonly id: string; - /** `tmpl.ContentPath` — empty means "not configured" (no bind emitted). */ - readonly contentPath: string; /** - * Notification mounts resolve through - * `legacyResolveNotificationContentPath` so the bind targets the same file - * config validation accepted (including the legacy `supabase/`-relative - * fallback); template mounts keep plain workdir resolution. + * Absolute HOST path, already resolved, containment-checked, AND + * read-verified by the caller (`start.handler.ts`'s + * `resolveKongEmailTemplateMounts`, via `legacyResolveEmailTemplateContentPath` + * plus a discarded `readFileSync`) — never a raw, unresolved + * `content_path`. There is no "not configured" sentinel here: the caller + * omits an entry entirely instead of including one with an empty path. + */ + readonly resolvedPath: string; + /** + * `true` for a mount derived from an ENABLED `auth.email.notification.*` + * entry (vs a `auth.email.template.*` entry) — caller-side bookkeeping + * only; this module no longer branches on it, since resolution (including + * the notification-specific legacy `supabase/`-relative fallback) already + * happened upstream, once, before `resolvedPath` was set. */ readonly notification?: boolean; } /** - * Resolves `contentPath` to an absolute HOST path (relative to the process's - * own working directory, the same project-root base used while validating - * `content_path`), joins it onto the fixed in-container email-template - * directory as `` (POSIX — the container is always - * Linux regardless of the host OS, hence `nodePath.posix.join`, not the - * platform-dependent `nodePath.join`), and formats the `rw` bind. Returns - * `undefined` for an empty `contentPath` (no bind appended). + * Formats one email-template bind mount: joins `mount.resolvedPath` onto the + * fixed in-container email-template directory as `` + * (POSIX — the container is always Linux regardless of the host OS, hence + * `nodePath.posix.join`, not the platform-dependent `nodePath.join`), and + * formats the `rw` bind. + * + * A pure formatter over an already-validated path — it makes no containment + * or existence claims of its own. `start.handler.ts` resolves, confines to + * the project root, and read-verifies every mount's `resolvedPath` exactly + * once, before any Docker work runs (see + * `LegacyKongEmailTemplateMount.resolvedPath`'s doc comment). */ -export function legacyBuildKongEmailTemplateBind( - mount: LegacyKongEmailTemplateMount, - workdir: string, -): string | undefined { - if (mount.contentPath.length === 0) return undefined; - const hostPath = mount.notification - ? legacyResolveNotificationContentPath(workdir, mount.contentPath) - : nodePath.isAbsolute(mount.contentPath) - ? mount.contentPath - : nodePath.resolve(workdir, mount.contentPath); +export function legacyBuildKongEmailTemplateBind(mount: LegacyKongEmailTemplateMount): string { const dockerPath = nodePath.posix.join( LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR, - `${mount.id}${nodePath.extname(hostPath)}`, + `${mount.id}${nodePath.extname(mount.resolvedPath)}`, ); - return `${hostPath}:${dockerPath}:rw`; + return `${mount.resolvedPath}:${dockerPath}:rw`; } const LEGACY_KONG_ENTRYPOINT_HEAD = @@ -249,11 +251,6 @@ export interface LegacyKongContainerSpecInput { * this builder a pure function of its `input`. */ readonly nginxWorkerProcesses: string; - /** - * `LegacyCliSettings.workdir` — used to resolve any relative - * {@link emailTemplateMounts} `contentPath` to an absolute host path. - */ - readonly workdir: string; /** * Every `config.auth.email.template.*`/enabled * `config.auth.email.notification.*` entry the caller has already @@ -288,9 +285,9 @@ export function legacyBuildKongContainerSpec( queryToken: legacyBuildKongQueryToken(input.apiKeys), }); - const binds = (input.emailTemplateMounts ?? []) - .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir)) - .filter((bind): bind is string => bind !== undefined); + const binds = (input.emailTemplateMounts ?? []).map((mount) => + legacyBuildKongEmailTemplateBind(mount), + ); const dockerPort = input.apiTlsEnabled ? 8443 : 8000; diff --git a/apps/cli/src/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/commands/start/services/kong.service.unit.test.ts index 76166a9fde..fdd94321dc 100644 --- a/apps/cli/src/commands/start/services/kong.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/kong.service.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { @@ -55,59 +52,24 @@ describe("legacyResolveKongNginxWorkerProcesses", () => { }); describe("legacyBuildKongEmailTemplateBind", () => { - test("returns undefined for an empty contentPath (start.go:528-530)", () => { + // Resolution (workdir-relative joins, the notification-specific legacy + // supabase/-relative fallback, absolute-path passthrough, containment, and + // read-verification) all moved to `start.handler.ts`'s + // `resolveKongEmailTemplateMounts` (CLI-2339's Kong-mount hardening pass) — + // see that function's own test coverage in `start.integration.test.ts` and + // `legacy-config-validate.unit.test.ts`'s `legacyResolveEmailTemplateContentPath` + // suite. This function is now a pure formatter over an already-resolved + // `mount.resolvedPath`; the remaining tests below only cover that formatting. + + test("builds the bind string from an already-resolved resolvedPath (start.go:531-538)", () => { expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work"), - ).toBeUndefined(); - }); - - test("resolves a relative contentPath against workdir (start.go:531-538)", () => { - expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "invite.html" }, "/work"), + legacyBuildKongEmailTemplateBind({ id: "invite", resolvedPath: "/work/invite.html" }), ).toBe("/work/invite.html:/home/kong/templates/email/invite.html:rw"); }); - test("notification mounts fall back to the legacy supabase-relative file", () => { - const workdir = mkdtempSync(join(tmpdir(), "kong-email-bind-")); - try { - mkdirSync(join(workdir, "supabase", "templates"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "templates", "n.html"), "

x

"); - expect( - legacyBuildKongEmailTemplateBind( - { - id: "password_changed_notification", - contentPath: "./templates/n.html", - notification: true, - }, - workdir, - ), - ).toBe( - `${join(workdir, "supabase", "templates", "n.html")}:/home/kong/templates/email/password_changed_notification.html:rw`, - ); - // template mounts keep plain workdir resolution even when the file is absent - expect( - legacyBuildKongEmailTemplateBind( - { id: "invite", contentPath: "./templates/n.html" }, - workdir, - ), - ).toBe(`${join(workdir, "templates", "n.html")}:/home/kong/templates/email/invite.html:rw`); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("leaves an absolute contentPath untouched", () => { - expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "/abs/invite.html" }, "/work"), - ).toBe("/abs/invite.html:/home/kong/templates/email/invite.html:rw"); - }); - - test("drops the extension when hostPath has none", () => { + test("drops the extension when resolvedPath has none", () => { expect( - legacyBuildKongEmailTemplateBind( - { id: "invite_notification", contentPath: "invite" }, - "/work", - ), + legacyBuildKongEmailTemplateBind({ id: "invite_notification", resolvedPath: "/work/invite" }), ).toBe("/work/invite:/home/kong/templates/email/invite_notification:rw"); }); }); @@ -150,7 +112,6 @@ const base: LegacyKongContainerSpecInput = { logflareId: "supabase_analytics_proj", poolerId: "supabase_pooler_proj", nginxWorkerProcesses: "1", - workdir: "/work", }; describe("legacyBuildKongContainerSpec", () => { @@ -229,12 +190,15 @@ describe("legacyBuildKongContainerSpec", () => { }); test("mounts every resolved email template bind (start.go:544-558)", () => { + // Every entry here is already resolved+containment-checked+read-verified by the + // caller (`start.handler.ts`'s `resolveKongEmailTemplateMounts`) — there is no + // "unconfigured" entry to filter downstream anymore, since the caller omits those + // entirely before this input is ever built. const spec = legacyBuildKongContainerSpec({ ...base, emailTemplateMounts: [ - { id: "invite", contentPath: "invite.html" }, - { id: "confirmation_notification", contentPath: "" }, - { id: "recovery_notification", contentPath: "/abs/recovery.html" }, + { id: "invite", resolvedPath: "/work/invite.html" }, + { id: "recovery_notification", resolvedPath: "/abs/recovery.html" }, ], }); expect(spec.binds).toEqual([ diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index e66bd5c581..6b4ad9e29c 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -2,6 +2,7 @@ * Native TS implementation of `start` — see `SIDE_EFFECTS.md` for the full * behavior contract. */ +import { readFileSync } from "node:fs"; import { inferFunctionsManifest } from "@supabase/config/effect"; import { resolveCliConfigSubtree } from "@supabase/config/internal"; import { Effect, FileSystem, Option, Path, Result } from "effect"; @@ -33,7 +34,9 @@ import { legacyAqua, legacyYellow } from "../../command-internal/legacy-colors.t import { legacyApiTlsCertReadErrorMessage, legacyApiTlsKeyReadErrorMessage, + legacyEmailContentPathReadErrorMessage, legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, } from "../../command-internal/legacy-config-validate.ts"; import { legacyIsContainerNotFoundMessage } from "../../command-internal/legacy-container-cli.ts"; import { legacyCheckDbToml } from "../../command-internal/legacy-db-config.toml-read.ts"; @@ -352,29 +355,78 @@ function resolveGotrueEnvInput(params: { }; } +/** + * Read-and-discard existence/readability check for one already-resolved + * `content_path` — same pattern as `legacy-local-config-values.ts`'s + * `readAuthEmailTemplateContent` and `push.auth-email-content.ts`'s + * `readTemplateContent`, reusing their established error message shape. + * Closes the gap where a resolved-but-never-read path (e.g. a `content_path` + * naming a missing file, only reachable when `auth.enabled = false`) would + * otherwise reach Docker unverified — the root-privileged daemon silently + * creates a directory at a bind-mounted host path that doesn't exist, so an + * unprivileged read here must succeed first. + */ +function readKongEmailTemplateContent( + section: "template" | "notification", + name: string, + resolvedPath: string, +): void { + try { + readFileSync(resolvedPath, "utf8"); + } catch (cause) { + throw new Error(legacyEmailContentPathReadErrorMessage(section, name, cause)); + } +} + /** * Kong's email template mounts: every configured template, then every - * ENABLED notification, suffixed `_notification`. + * ENABLED notification, suffixed `_notification`. Resolves, containment- + * checks, and read-verifies each `content_path` HERE — once, before any + * Docker work — via `legacyResolveEmailTemplateContentPath` (the same check + * config validation and `config push` apply) followed by + * `readKongEmailTemplateContent`. The resulting `resolvedPath` is what the + * caller threads straight into `legacyBuildKongEmailTemplateBind`; nothing + * re-derives it later, right before the `docker create` call for Kong + * (potentially minutes later, after image pulls/Postgres bring-up/ + * migrations) — closing the TOCTOU window between an earlier + * validation-only pass and Kong's own independent re-resolution. * - * Path resolution happens in the bind builder — see - * `LegacyKongEmailTemplateMount.notification`. + * Skips (never throws for) an entry whose resolver returns `undefined` — per + * its own contract that only happens for an empty/absent `content_path`, + * which should be unreachable here since Kong's set is built from configured + * entries, but this omits the mount defensively rather than crashing. */ -function buildKongEmailTemplateMounts( +function resolveKongEmailTemplateMounts( email: LegacyResolvedAuthEmail, + workdir: string, ): ReadonlyArray { - return [ - ...Object.entries(email.template).map(([id, template]) => ({ - id, + const mounts: Array = []; + for (const [id, template] of Object.entries(email.template)) { + const resolvedPath = legacyResolveEmailTemplateContentPath({ + section: "template", + name: id, contentPath: template.content_path, - })), - ...Object.entries(email.notification) - .filter(([, notification]) => notification.enabled) - .map(([id, notification]) => ({ - id: `${id}_notification`, - contentPath: notification.content_path, - notification: true, - })), - ]; + contentPresent: false, + base: workdir, + }); + if (resolvedPath === undefined) continue; + readKongEmailTemplateContent("template", id, resolvedPath); + mounts.push({ id, resolvedPath }); + } + for (const [id, notification] of Object.entries(email.notification)) { + if (!notification.enabled) continue; + const resolvedPath = legacyResolveEmailTemplateContentPath({ + section: "notification", + name: id, + contentPath: notification.content_path, + contentPresent: false, + base: workdir, + }); + if (resolvedPath === undefined) continue; + readKongEmailTemplateContent("notification", id, resolvedPath); + mounts.push({ id: `${id}_notification`, resolvedPath, notification: true }); + } + return mounts; } /** @@ -461,6 +513,24 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta message: cause instanceof Error ? cause.message : String(cause), }), }); + // Kong mounts every configured template (regardless of `auth.enabled` — + // Kong is the stack's mandatory gateway) and every ENABLED notification's + // `content_path`, unconditionally. Resolving, containment-checking, AND + // read-verifying every path happens exactly ONCE, here, before any Docker + // work — not only inside `legacyResolveLocalConfigValues`'s own + // `auth.enabled`-gated `readAuthEmailTemplateContent` call. The resulting + // `resolvedPath`s are threaded straight into the Kong container-spec + // input below instead of being discarded and re-derived later inside + // `legacyBuildKongEmailTemplateBind`, which closes the TOCTOU window + // between this pass and Kong's `docker create` call (potentially minutes + // later, after image pulls/Postgres bring-up/migrations). + const kongEmailTemplateMounts = yield* Effect.try({ + try: () => resolveKongEmailTemplateMounts(resolvedEmail, cliSettings.workdir), + catch: (cause) => + new LegacyStartInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); // Every `time.Duration`-shaped config field — including these 5 — must // fail fast, before `start` touches Docker at all: these fields are only // parsed inside GoTrue's own env builder (`gotrue.service.ts`), which @@ -1323,8 +1393,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta logflareId: logflareContainerName, poolerId: poolerContainerName, nginxWorkerProcesses: legacyResolveKongNginxWorkerProcesses(projectEnvValues), - workdir: cliSettings.workdir, - emailTemplateMounts: buildKongEmailTemplateMounts(resolvedEmail), + emailTemplateMounts: kongEmailTemplateMounts, }), }; } diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 4b39a220ef..01ede6ec51 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -1340,6 +1340,70 @@ describe("legacy start integration", () => { expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); + + it.live( + "rejects an out-of-root auth.email.template content_path before any Docker work, even with auth disabled", + () => { + // `auth.enabled = false` skips `legacyResolveLocalConfigValues`'s own + // `readAuthEmailTemplateContent` gate, but Kong mounts every configured template + // unconditionally (`buildKongEmailTemplateMounts`, regardless of `auth.enabled`) — the + // eager pre-Docker containment pass added to `start.handler.ts` (CLI-2339) is what closes + // that gap, resolving+checking every template/enabled-notification `content_path` before + // `create` is ever spawned. + const { layer, child } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "/etc/hosts"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStartInvalidConfigError"); + // The thrown message echoes the DECLARED content_path value (quoted), not the + // fully-canonicalized target — see `legacyResolveEmailTemplateContentPath`'s own doc + // comment for why (a deliberate recon-leak mitigation). + expect(serialized).toContain( + 'Invalid config for auth.email.template.invite.content_path: \\"/etc/hosts\\" resolves outside the project root', + ); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails on a missing (but in-root) auth.email.template content_path before any Docker work, even with auth disabled", + () => { + // `auth.enabled = false` skips `legacy-local-config-values.ts`'s own gated + // `readAuthEmailTemplateContent` read entirely — this content_path resolves IN-ROOT + // (passes containment cleanly), so the only thing that can still catch a missing file + // here is the read-verification `resolveKongEmailTemplateMounts` added in `start. + // handler.ts` (CLI-2339's Kong-mount hardening pass). Without it, this would have + // reached `docker create` with a bind-mount source that doesn't exist on disk. + const { layer, workdir, child } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "./templates/missing.html"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStartInvalidConfigError"); + expect(serialized).toContain( + "Invalid config for auth.email.template.invite.content_path:", + ); + // Distinguishes this from the containment-rejection test above: this content_path + // never escapes the project root at all, so a regression back to "no read- + // verification" would have this test's exit succeed instead of fail. + expect(serialized).not.toContain("resolves outside the project root"); + } + expect(existsSync(join(workdir, "templates", "missing.html"))).toBe(false); + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("happy path", () => { diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index e34a0249ea..d56789b467 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -8,13 +8,14 @@ which branch they linked) can discover which project/branch it's on without a se ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | -| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | -| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | -| `/supabase/.temp/project-ref` | plain text | always (soft) — the linked-state "currently linked ref" lookup (CLI-2167 follow-up, TS-only) | -| `/supabase/.temp/linked-project.json` | JSON | always (soft), once linked — determines plain-project-vs-branch state and the display name (CLI-2167 follow-up, TS-only) | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | +| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true`; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/.temp/project-ref` | plain text | always (soft) — the linked-state "currently linked ref" lookup (CLI-2167 follow-up, TS-only) | +| `/supabase/.temp/linked-project.json` | JSON | always (soft), once linked — determines plain-project-vs-branch state and the display name (CLI-2167 follow-up, TS-only) | ## Files Written diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index de9229f891..0f36839955 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -7,9 +7,10 @@ model (see the CLI-1324 plan's "Critical architectural finding" for why). ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | -------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | default path only, only when `auth.enabled`, for every configured template and every notification with `enabled = true`, as part of `legacyResolveLocalConfigValues`'s own `Config.Validate` pass; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | ## Files Written @@ -129,8 +130,17 @@ Same payload as `json`, delivered as a `result` NDJSON event. ## Notes - `--project-id` and `--all` are **directory-independent** pure Docker-label filters — - neither reads `config.toml`. Only the no-flags default path resolves the project id + neither reads `config.toml`, so neither is subject to the `content_path` containment + check below; only the no-flags default path resolves the project id from `LegacyCliSettings.workdir` (env → config.toml `project_id` → workdir basename). +- **The default path VALIDATES config, including the `content_path` containment check + above, BEFORE any Docker teardown call.** `resolveSearchProjectIdFilter` + (`stop.handler.ts`) loads and validates config (`legacyResolveLocalConfigValues`) to + resolve the project id filter, and this runs before `legacyDockerRemoveAll` is ever + invoked — so a config-validation failure here (a malformed config, or an + `auth.email.*.content_path` that resolves outside the project root) fails the command + and the running stack is **not** torn down. `--all`/`--project-id` bypass config + loading entirely (see the bullet above) and so are unaffected by this failure mode. - The hidden `--backup` flag exists only for CLI surface parity with the old Go CLI — it has **no effect**. The old Go CLI declared it but never wired its value into anything, so it always deleted volumes based on `!noBackup` regardless of `--backup`. The TS port From 0cbf09ce27824b01abd6f74b047592791f160342 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:20:00 +0000 Subject: [PATCH 05/57] test(cli): cover `branches` get, update and disable (CLI-2327) (#6492) ## TL;DR Adds live e2e coverage for `branches get`, `branches update` and `branches disable` ## whats introduced? - `branches get`: creates a branch, fetches it by name and asserts the pretty connection table renders - `branches update`: creates a branch, renames it with `--name --output json`, asserts the confirmation and payload, then proves the new name resolves through `branches get` - `branches disable`: creates and deletes a branch so branching is enabled with no preview branches left, disables preview branching for the project and asserts the confirmation on stdout ## ref: - closes: CLI-2327 - passed here: https://github.com/supabase/cli/actions/runs/34112181467 --- .../branches/create/create.live.test.ts | 20 +---- .../branches/delete/delete.live.test.ts | 24 ++---- .../branches/disable/disable.live.test.ts | 49 +++++++++++ .../commands/branches/get/get.live.test.ts | 52 ++++++++++++ .../commands/branches/list/list.live.test.ts | 24 ++---- .../branches/update/update.live.test.ts | 83 +++++++++++++++++++ apps/cli/tests/helpers/live.ts | 61 ++++++++++++++ 7 files changed, 261 insertions(+), 52 deletions(-) create mode 100644 apps/cli/src/commands/branches/disable/disable.live.test.ts create mode 100644 apps/cli/src/commands/branches/get/get.live.test.ts create mode 100644 apps/cli/src/commands/branches/update/update.live.test.ts diff --git a/apps/cli/src/commands/branches/create/create.live.test.ts b/apps/cli/src/commands/branches/create/create.live.test.ts index c4557beb19..f5a1a1ade4 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -1,23 +1,7 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; -import { test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; - -async function cleanupBranch( - cli: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>, - name: string, - ref: string, -): Promise { - const deleted = await cli(["branches", "delete", name, "--project-ref", ref, "--yes"]); - if ( - deleted.exitCode !== 0 && - !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) - ) { - throw new Error( - `branches delete cleanup failed (exit ${deleted.exitCode})\n${deleted.stdout}\n${deleted.stderr}`, - ); - } -} +import { removeLiveBranch, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; test("creates a preview branch", async ({ cli, project }) => { const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; @@ -31,7 +15,7 @@ test("creates a preview branch", async ({ cli, project }) => { targetError = error; } finally { try { - await cleanupBranch(cli, name, project.ref); + await removeLiveBranch(cli, project, name); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/delete/delete.live.test.ts b/apps/cli/src/commands/branches/delete/delete.live.test.ts index 55318b219c..bfe3c36e71 100644 --- a/apps/cli/src/commands/branches/delete/delete.live.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.live.test.ts @@ -1,7 +1,12 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; +import { + removeLiveBranch, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; test("deletes a preview branch", async ({ cli, project }) => { const name = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; @@ -22,22 +27,7 @@ test("deletes a preview branch", async ({ cli, project }) => { } finally { if (mayExist) { try { - const cleanup = await cli([ - "branches", - "delete", - name, - "--project-ref", - project.ref, - "--yes", - ]); - if ( - cleanup.exitCode !== 0 && - !/not found|does not exist/i.test(`${cleanup.stdout}\n${cleanup.stderr}`) - ) { - cleanupError = new Error( - `branches delete cleanup failed:\n${cleanup.stdout}\n${cleanup.stderr}`, - ); - } + await removeLiveBranch(cli, project, name); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/disable/disable.live.test.ts b/apps/cli/src/commands/branches/disable/disable.live.test.ts new file mode 100644 index 0000000000..fc6a528458 --- /dev/null +++ b/apps/cli/src/commands/branches/disable/disable.live.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { + awaitLiveBranchesRemoved, + removeLiveBranch, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; + +test("disables preview branching", async ({ cli, project }) => { + const name = `cli-e2e-disable-${randomUUID().slice(0, 8)}`; + let mayExist = false; + let targetError: unknown; + let cleanupError: unknown; + try { + // `branches disable` is project-wide and leaves branching off for the rest + // of the serial run, and the platform refuses it (422) while any non-default + // branch exists. Creating then deleting a branch proves branching was on, + // and waiting until no non-default branch is listed (`branches delete` + // returns before the branch is gone) gives the assertion below a real, + // empty branching setup; sibling tests each create their own branch first, + // which re-enables branching for them. + mayExist = true; + const created = await cli(["branches", "create", name, "--project-ref", project.ref]); + requireLiveSuccess(created, "branches create"); + + const removed = await cli(["branches", "delete", name, "--project-ref", project.ref, "--yes"]); + if (removed.exitCode === 0) mayExist = false; + requireLiveSuccess(removed, "branches delete"); + await awaitLiveBranchesRemoved(cli, project); + + const disabled = await cli(["branches", "disable", "--project-ref", project.ref]); + expect(disabled.exitCode, disabled.stderr).toBe(0); + expect(disabled.stdout).toContain(`Disabled preview branching for project: ${project.ref}`); + } catch (error) { + targetError = error; + } finally { + if (mayExist) { + try { + await removeLiveBranch(cli, project, name); + } catch (error) { + cleanupError = error; + } + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/commands/branches/get/get.live.test.ts b/apps/cli/src/commands/branches/get/get.live.test.ts new file mode 100644 index 0000000000..e65790ea63 --- /dev/null +++ b/apps/cli/src/commands/branches/get/get.live.test.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { + removeLiveBranch, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; + +test("gets a preview branch by name", async ({ cli, project }) => { + const name = `cli-e2e-get-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "branches", + "create", + name, + "--project-ref", + project.ref, + "--output-format", + "json", + ]); + requireLiveSuccess(created, "branches create"); + branchRef = (JSON.parse(created.stdout) as { project_ref: string }).project_ref; + expect(branchRef, created.stdout).toBeTruthy(); + + const result = await cli(["branches", "get", name, "--project-ref", project.ref]); + expect(result.exitCode, result.stderr).toBe(0); + // The pretty table prints the branch password and JWT secret, so failures + // must not echo stdout: assert through booleans with a secret-free message. + expect( + /HOST.*STATUS/u.test(result.stdout), + `branches get did not render the table header\nstderr:\n${result.stderr}`, + ).toBe(true); + expect( + result.stdout.includes(branchRef), + `branches get table has no cell containing branch ref ${branchRef}\nstderr:\n${result.stderr}`, + ).toBe(true); + } catch (error) { + targetError = error; + } finally { + try { + await removeLiveBranch(cli, project, branchRef ?? name); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/src/commands/branches/list/list.live.test.ts b/apps/cli/src/commands/branches/list/list.live.test.ts index 0aa6d921ba..e6ba2b9f3e 100644 --- a/apps/cli/src/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/commands/branches/list/list.live.test.ts @@ -1,7 +1,12 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; -import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; +import { + removeLiveBranch, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; test("lists a preview branch for the project", async ({ cli, project }) => { const name = `cli-e2e-list-${randomUUID().slice(0, 8)}`; @@ -26,22 +31,7 @@ test("lists a preview branch for the project", async ({ cli, project }) => { targetError = error; } finally { try { - const deleted = await cli([ - "branches", - "delete", - name, - "--project-ref", - project.ref, - "--yes", - ]); - if ( - deleted.exitCode !== 0 && - !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) - ) { - cleanupError = new Error( - `branches delete cleanup failed:\n${deleted.stdout}\n${deleted.stderr}`, - ); - } + await removeLiveBranch(cli, project, name); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/update/update.live.test.ts b/apps/cli/src/commands/branches/update/update.live.test.ts new file mode 100644 index 0000000000..f2bc52c3bb --- /dev/null +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; + +import { + removeLiveBranch, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../tests/helpers/live.ts"; + +test("renames a preview branch", async ({ cli, project }) => { + const name = `cli-e2e-update-${randomUUID().slice(0, 8)}`; + const renamed = `${name}-renamed`; + let branchRef: string | undefined; + let targetError: unknown; + let cleanupError: unknown; + try { + const created = await cli([ + "branches", + "create", + name, + "--project-ref", + project.ref, + "--output-format", + "json", + ]); + requireLiveSuccess(created, "branches create"); + branchRef = (JSON.parse(created.stdout) as { project_ref: string }).project_ref; + expect(branchRef, created.stdout).toBeTruthy(); + + // `--output json` keeps stdout payload-only and sends the confirmation to stderr. + const updated = await cli([ + "branches", + "update", + name, + "--project-ref", + project.ref, + "--name", + renamed, + "--output", + "json", + ]); + expect(updated.exitCode, updated.stderr).toBe(0); + expect(updated.stderr).toContain("Updated preview branch"); + expect(JSON.parse(updated.stdout)).toMatchObject({ name: renamed }); + + // Right after the rename the platform can still miss the new name (`get` by + // name is a server-side lookup and returned 404 for it), so after one + // fail-fast read the lookup is polled (2s apart, 60s deadline, each attempt + // bounded) until it resolves. The first read aborts on anything but a 404. + // The proof carries stderr only: `get` prints secrets on stdout. + const prove = async (): Promise => { + const proof = await cli(["branches", "get", renamed, "--project-ref", project.ref], { + exitTimeoutMs: 20_000, + }); + if (proof.exitCode === 0) return "found"; + if (!/status 404\b/u.test(proof.stderr)) { + throw new Error( + `branches get ${renamed} failed (exit ${proof.exitCode})\nstderr:\n${proof.stderr}`, + ); + } + return `not found (exit ${proof.exitCode})\nstderr:\n${proof.stderr}`; + }; + if ((await prove()) !== "found") { + await expect + .poll(prove, { + interval: 2_000, + timeout: 60_000, + message: `branches get ${renamed} still does not find the renamed branch`, + }) + .toBe("found"); + } + } catch (error) { + targetError = error; + } finally { + try { + await removeLiveBranch(cli, project, branchRef ?? name); + } catch (error) { + cleanupError = error; + } + } + throwWithCleanup(targetError, cleanupError === undefined ? [] : [cleanupError]); +}); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 413d61f24f..5d244b4f06 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -137,6 +137,23 @@ export async function removeStorageLiveObject( } } +/** Exact cleanup for branches live tests by name or ref; deleting an already-removed branch is tolerated. */ +export async function removeLiveBranch( + cli: LiveFixtures["cli"], + project: LiveProject, + branch: string, +): Promise { + const removed = await cli(["branches", "delete", branch, "--project-ref", project.ref, "--yes"]); + if ( + removed.exitCode !== 0 && + !/not found|does not exist|status 404\b/i.test(`${removed.stdout}\n${removed.stderr}`) + ) { + throw new Error( + `branches delete cleanup for ${branch} failed (exit ${removed.exitCode})\n${removed.stdout}\n${removed.stderr}`, + ); + } +} + /** Flags for experimental-gated live tests that address the shared project by * ref rather than linking it (contrast `storageLiveFlags`). */ export function experimentalProjectLiveFlags(project: LiveProject): ReadonlyArray { @@ -218,6 +235,50 @@ export async function expectPostgresConfigLiveOverride( await expect.poll(read, { interval: 2_000, timeout: 60_000, message: label }).toBe(expected); } +/** + * Waits until `branches list` shows no non-default branch on the live project. + * `branches delete` returns before the platform finishes tearing the branch + * down, and `branches disable` is refused ("Please delete all non-default + * branches before disabling branching.") while any non-default branch still + * exists, so a caller that needs an empty branching setup does one fail-fast + * read and then polls the list (2s apart, 120s deadline, each attempt bounded). + */ +export async function awaitLiveBranchesRemoved( + cli: LiveFixtures["cli"], + project: LiveProject, +): Promise { + const label = "branches list while awaiting branch removal"; + const read = async (): Promise> => { + const listed = await cli( + ["branches", "list", "--output", "json", "--project-ref", project.ref], + { exitTimeoutMs: 20_000 }, + ); + requireCliSuccess(listed, label); + let branches: unknown; + try { + branches = JSON.parse(listed.stdout); + } catch { + branches = undefined; + } + if (!Array.isArray(branches)) { + throw new Error( + `${label}: unexpected branches list payload\nstdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`, + ); + } + return branches + .filter((branch: { is_default: boolean }) => !branch.is_default) + .map((branch: { name: string }) => branch.name); + }; + if ((await read()).length === 0) return; + await expect + .poll(read, { + interval: 2_000, + timeout: 120_000, + message: "non-default preview branches still exist", + }) + .toEqual([]); +} + /** * Unique migration version for a live test: a sortable `YYYYMMDDHHMMSS` UTC * stamp plus four random digits, so it always orders after any conventional From 84d52ca0713d6eade873c41114fa03f23f4f17d9 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:34:37 +0000 Subject: [PATCH 06/57] fix(cli): consume `--log-level` value (CLI-2329) (#6483) ## TL;DR fixes `supabase sso update --log-level error ` failing with `accepts 1 arg(s), received 2` by registering the built-in `--log-level` as value taking in the raw argv scanners.. ## whats biting the user? `--log-level` shows up in every command's help and the parser accepts it, but the raw argv scanners did not know it consumes a value so `error` was counted as an extra positional and the command refused to run. A typed `--log-level` also killed shell completion for the rest of the line. ## now fixed by: - registering `log-level` in `PERSISTENT_VALUE_FLAG_NAMES` and `globalFlagsWithValues`, so positional counting consumes its value like every other global - registering the built-in flags where output format and shell completion resolve, so `--version` output and tab completion keep working around them also added regression tests for the issue's exact spelling plus the pre-path, inline, completion, and version spellings ## ref: - closes CLI-2329 - closes https://github.com/supabase/cli/issues/6482 --- apps/cli/src/cli/legacy-complete.ts | 27 ++--- apps/cli/src/cli/legacy-complete.unit.test.ts | 62 ++++++++++- .../legacy-db-target-flags.ts | 6 ++ .../sso/update/update.integration.test.ts | 11 +- .../cli/src/docs/legacy-docs-introspection.ts | 22 ++-- apps/cli/src/shared/cli/agent-output.ts | 102 +++++++++++++----- .../src/shared/cli/agent-output.unit.test.ts | 51 +++++++++ apps/cli/src/shared/cli/cobra-flag-groups.ts | 41 +++++-- .../shared/cli/cobra-flag-groups.unit.test.ts | 52 ++++++++- apps/cli/src/shared/cli/global-flags.ts | 11 +- apps/cli/src/shared/cli/run.ts | 60 +++++++---- apps/cli/src/shared/cli/run.unit.test.ts | 19 ++++ apps/cli/src/shared/legacy/global-flags.ts | 31 +++--- 13 files changed, 391 insertions(+), 104 deletions(-) diff --git a/apps/cli/src/cli/legacy-complete.ts b/apps/cli/src/cli/legacy-complete.ts index d7d90e8437..8b218efe62 100644 --- a/apps/cli/src/cli/legacy-complete.ts +++ b/apps/cli/src/cli/legacy-complete.ts @@ -230,8 +230,9 @@ function legacyFlagDescriptorFromParam(param: Param.AnyFlag): LegacyFlagDescript * flag-name candidates: `InheritedFlags().VisitAll` (every ancestor's global * and shared flags, as ONE pflag-alphabetically-sorted block), followed by * `NonInheritedFlags().VisitAll` (the resolved command's own global flags, - * its own `--help`, root's own `--version`, and its own local flags, as a - * SECOND, separately-sorted block) — pflag's `FlagSet.VisitAll` walks + * its own `--help`, the `--log-level`/`--wizard`/`--completions` built-ins, + * root's own `--version`, and its own local flags, as a SECOND, + * separately-sorted block) — pflag's `FlagSet.VisitAll` walks * `sortedFormalFlags`, which sorts strictly by each flag's canonical long * name: `db dump -` lists `--agent`, `--create-ticket`, `--debug`, ... * alphabetically, THEN a second alphabetical run starting `--data-only`, @@ -255,18 +256,8 @@ export function legacyCollectInScopeFlags( const finalCommand = commandChain[commandChain.length - 1] ?? root; const ancestors = commandChain.slice(0, -1); - // `GlobalFlag.Completions`/`GlobalFlag.LogLevel` are TS-only framework - // additions with no Go/cobra equivalent. They are normally only injected - // via `GlobalFlag.BuiltIns` at parse time (never stored on a command's own - // `.globalFlags`), so this filter is a defensive guard rather than - // something that changes today's output — kept explicit so it stays true - // if that ever changes. const globalFlagParamsOf = (command: Command.Command.Any): ReadonlyArray => - legacyInternalCommand(command) - .globalFlags.filter( - (entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel, - ) - .map((entry) => entry.flag); + legacyInternalCommand(command).globalFlags.map((entry) => entry.flag); const inheritedParams: Array = [ ...ancestors.flatMap(globalFlagParamsOf), @@ -275,6 +266,16 @@ export function legacyCollectInScopeFlags( const ownParams: Array = [ ...globalFlagParamsOf(finalCommand), GlobalFlag.Help.flag, + // The built-ins `--log-level`, `--wizard`, and `--completions` are part + // of every resolved command's real flag set (`GlobalFlag.BuiltIns`, shown + // in `--help`), so the strict flag walk must resolve them or a typed + // `--log-level error` poisons the whole line like an unknown flag + // (issue #6482). Unlike `--help`/root `--version`, whose short-circuit + // below ends the completion request, these three keep the line + // completing normally. + GlobalFlag.LogLevel.flag, + GlobalFlag.Wizard.flag, + GlobalFlag.Completions.flag, // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on // the root command (gated on `c.Version != ""`, and non-persistent) — it // is never inherited by subcommands the way `--help` is. diff --git a/apps/cli/src/cli/legacy-complete.unit.test.ts b/apps/cli/src/cli/legacy-complete.unit.test.ts index 3b8aa4dc1e..fbd8a9fd02 100644 --- a/apps/cli/src/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/cli/legacy-complete.unit.test.ts @@ -51,6 +51,40 @@ describe("legacyRespondToComplete", () => { expect(result?.candidates.map((c) => c.name)).toContain("--debug"); }); + it("offers the built-in --log-level flag like --help shows it", () => { + const result = legacyRespondToComplete(legacyRoot, ["__complete", "--log"]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("--log-level"); + }); + + it("offers the built-in --wizard flag and keeps completing after it", () => { + const offered = legacyRespondToComplete(legacyRoot, ["__complete", "--wiz"]); + expect(offered?.candidates.map((c) => c.name)).toContain("--wizard"); + + // A boolean built-in must not poison the line or consume the next token. + const after = legacyRespondToComplete(legacyRoot, ["__complete", "--wizard", ""]); + expect(after?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(after?.candidates.map((c) => c.name)).toContain("branches"); + }); + + it("offers the built-in --completions flag and keeps completing after its shell value", () => { + const offered = legacyRespondToComplete(legacyRoot, ["__complete", "--comp"]); + expect(offered?.candidates.map((c) => c.name)).toContain("--completions"); + + const after = legacyRespondToComplete(legacyRoot, ["__complete", "--completions", "bash", ""]); + expect(after?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(after?.candidates.map((c) => c.name)).toContain("branches"); + + // An invalid shell value still poisons the line, matching the real parse. + const invalid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "--completions", + "powershell", + "", + ]); + expect(invalid).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + it("offers an ancestor's shared flag (Command.withSharedFlags) from a resolved leaf command", () => { // `--no-cache` is declared once on the `db schema declarative` group via // Command.withSharedFlags (declarative.shared.ts) and must be visible from @@ -114,6 +148,29 @@ describe("legacyRespondToComplete", () => { expect(result?.candidates.map((c) => c.name)).toContain("migration"); }); + it("lists subcommands after the built-in --log-level and its value", () => { + // `--log-level error ""` used to return zero candidates: the built-in + // never entered the in-scope flag set, so the strict flag walk treated + // it like an unknown flag and poisoned the line (issue #6482). An + // invalid value must still poison it, matching the real parse. + const result = legacyRespondToComplete(legacyRoot, [ + "__complete", + "--log-level", + "error", + "", + ]); + expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); + expect(result?.candidates.map((c) => c.name)).toContain("sso"); + + const invalid = legacyRespondToComplete(legacyRoot, [ + "__complete", + "--log-level", + "bogus", + "", + ]); + expect(invalid).toEqual({ candidates: [], directive: LegacyCompletionDirective.Default }); + }); + it("still resolves and lists subcommands when the global flag appears before the group", () => { const result = legacyRespondToComplete(legacyRoot, ["__complete", "--debug", "db", ""]); expect(result?.directive).toBe(LegacyCompletionDirective.NoFileComp); @@ -1326,12 +1383,13 @@ describe("legacyCollectInScopeFlags", () => { // resolved command's own set — TWO separately-sorted runs, not one // merged alphabetical list: `db dump -` lists --agent, --create-ticket, // --debug, ... alphabetically, THEN a second alphabetical run starting - // --data-only, --db-url, --dry-run, .... + // --completions (the built-ins join the own block), --data-only, + // --db-url, --dry-run, .... const { commandChain } = legacyResolveCommandPath(legacyRoot, ["db", "dump"]); const names = legacyCollectInScopeFlags(legacyRoot, commandChain).map((flag) => flag.name); const inheritedEnd = names.indexOf("yes"); // last inherited flag, alphabetically - const ownStart = names.indexOf("data-only"); // first own/local flag, alphabetically + const ownStart = names.indexOf("completions"); // first own flag, alphabetically (a built-in) expect(inheritedEnd).toBeGreaterThanOrEqual(0); expect(ownStart).toBeGreaterThan(inheritedEnd); diff --git a/apps/cli/src/command-internal/legacy-db-target-flags.ts b/apps/cli/src/command-internal/legacy-db-target-flags.ts index 6aad7b473d..bb1e1998a6 100644 --- a/apps/cli/src/command-internal/legacy-db-target-flags.ts +++ b/apps/cli/src/command-internal/legacy-db-target-flags.ts @@ -97,6 +97,12 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "network-id", "dns-resolver", "agent", + // The CLI library's built-in `--log-level` is deliberately not listed: an + // argv giving it a flag-shaped value fails the real parse before any + // scanner here runs, so nothing can mis-consume around it (issue #6482 + // registered it only where positional counting depends on it). The + // `--completions` action prints and exits before any handler runs, so + // these scans never see it either. // Every other value-consuming flag declared directly across commands/ // (CLI-1896 review follow-up — see the doc comment above). "add-domains", diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 0b74478e61..4a0f6fe348 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -648,7 +648,7 @@ describe("legacy sso update integration", () => { // Binary-verified Go behaviour: `--domains --profile staging ` // arity-errors because pflag hands `--profile` to `--domains` and // `staging` becomes positional. The scan must know the root's - // persistent value flags (`cmd/root.go:324-333`) to see this. + // persistent value flags (`cmd/root.go:337-348`) to see this. const { layer, api } = setup({ cliArgs: ["sso", "update", "--domains", "--profile", "staging", VALID_PROVIDER_ID], }); @@ -671,11 +671,18 @@ describe("legacy sso update integration", () => { // Regression guards for the re-count: pflag consumes these globals' // values (`--workdir .`, `--output-format json`, `-o json`), so none // of them may register as a second positional — each invocation must - // sail through to the PUT exactly as before. + // sail through to the PUT exactly as before. The post-path + // `--log-level` variant pins issue #6482 (its value mis-counted as a + // positional); the pre-path variant already passed pre-fix via the + // unanchored fail-open scan and guards that the NEWLY ANCHORED path + // keeps accepting a valid invocation — the anchoring assertion itself + // lives in `cobra-flag-groups.unit.test.ts`. const argvVariants: ReadonlyArray> = [ ["sso", "update", "--workdir", ".", VALID_PROVIDER_ID], ["sso", "update", "--output-format", "json", VALID_PROVIDER_ID], ["sso", "update", "-o", "json", VALID_PROVIDER_ID], + ["sso", "update", "--log-level", "error", VALID_PROVIDER_ID], + ["--log-level", "error", "sso", "update", VALID_PROVIDER_ID], ]; return Effect.gen(function* () { for (const cliArgs of argvVariants) { diff --git a/apps/cli/src/docs/legacy-docs-introspection.ts b/apps/cli/src/docs/legacy-docs-introspection.ts index 19ae0b2154..ddf88531b0 100644 --- a/apps/cli/src/docs/legacy-docs-introspection.ts +++ b/apps/cli/src/docs/legacy-docs-introspection.ts @@ -1,5 +1,4 @@ -import { GlobalFlag } from "effect/unstable/cli"; -import type { Command, Param, Primitive } from "effect/unstable/cli"; +import type { Command, GlobalFlag, Param, Primitive } from "effect/unstable/cli"; /** * `.config.flags`/`.config.arguments` (a command's own declared params), @@ -75,21 +74,18 @@ export function legacyFlattenSubcommands( } /** - * A command's user-facing scoped global flag params. `GlobalFlag.Completions` - * and `GlobalFlag.LogLevel` are TS-only framework additions with no Go/cobra - * equivalent; they are normally only injected via `GlobalFlag.BuiltIns` at - * parse time (never stored on a command's own `.globalFlags`), so the filter - * is a defensive guard rather than something that changes today's output — - * kept explicit so it stays true if that ever changes. + * A command's user-facing scoped global flag params — the flags commands + * declare themselves. The parser's built-ins (`GlobalFlag.BuiltIns`) are only + * injected at parse time and never stored on a command's `.globalFlags`, so + * the reference omits them by construction (no filter needed — same fact that + * let `legacy-complete.ts` drop its similar guard, issue #6482). Whether + * the reference should document the built-ins that `--help` and completion + * now show is a docs-surface decision tracked as a follow-up. */ export function legacyUserGlobalFlagParams( command: Command.Command.Any, ): ReadonlyArray { - return legacyCommandInternals(command) - .globalFlags.filter( - (entry) => entry !== GlobalFlag.Completions && entry !== GlobalFlag.LogLevel, - ) - .map((entry) => entry.flag); + return legacyCommandInternals(command).globalFlags.map((entry) => entry.flag); } /** diff --git a/apps/cli/src/shared/cli/agent-output.ts b/apps/cli/src/shared/cli/agent-output.ts index e0a86eeff9..96eb621ca6 100644 --- a/apps/cli/src/shared/cli/agent-output.ts +++ b/apps/cli/src/shared/cli/agent-output.ts @@ -1,5 +1,6 @@ import { Option } from "effect"; import type { OutputFormat } from "../output/types.ts"; +import { GLOBAL_VALUE_FLAG_TOKENS } from "./cobra-flag-groups.ts"; // The union of every legacy command's `--output` values (see // `shared/legacy/global-flags.ts`): resource commands use `env|pretty|json|toml|yaml`, @@ -90,52 +91,100 @@ function agentOverrideFromArg(value: string | undefined): AgentOverride { } } +// These predicates run pre-parse to pick the formatter a built-in ACTION +// renders through, so they must also know the CLI library's built-in flags: +// `--completions bash --version` serves the Version action (Version precedes +// Completions), and missing entries here rendered it as JSON under agent +// detection instead of the plain version line. The flag set is the shared +// derived registry, not a fourth hand-written copy (issue #6482). function isRootValueFlag(arg: string): boolean { - return ( - arg === "--output-format" || - arg === "--output" || - arg === "-o" || - arg === "--profile" || - arg === "--workdir" || - arg === "--network-id" || - arg === "--dns-resolver" || - arg === "--agent" - ); + return GLOBAL_VALUE_FLAG_TOKENS.has(arg); } function isRootValueFlagWithInlineValue(arg: string): boolean { - return ( - arg.startsWith("--output-format=") || - arg.startsWith("--output=") || - arg.startsWith("-o=") || - (arg.length > 2 && arg.startsWith("-o")) || - arg.startsWith("--profile=") || - arg.startsWith("--workdir=") || - arg.startsWith("--network-id=") || - arg.startsWith("--dns-resolver=") || - arg.startsWith("--agent=") - ); + // Attached `-o` — kept from the pre-derivation predicate; the + // shipped parser rejects this spelling, so it only ever classifies argv + // that already fails the parse. + if (arg.length > 2 && arg.startsWith("-o")) return true; + for (const token of GLOBAL_VALUE_FLAG_TOKENS) { + if (arg.startsWith(`${token}=`)) return true; + } + return false; +} + +const ROOT_BOOLEAN_FLAGS: ReadonlyArray = [ + "--debug", + "--experimental", + "--yes", + "--create-ticket", + "--wizard", +]; + +/** Bare or inline (`--flag` / `--flag=`) occurrence of `name`. */ +function isFlagOccurrence(arg: string, name: string): boolean { + return arg === name || arg.startsWith(`${name}=`); } +/** + * Inline values the CLI's boolean primitive ACCEPTS (lowercase only) — an + * acceptance set: any of these serves the flag's action, `=false` included. + * `run.ts`'s `PFLAG_BOOL_TRUE` answers a DIFFERENT question (ParseBool + * truthiness, for the pflag-modeled upgrade-notice scans); do not merge them. + */ +const BOOLEAN_FLAG_VALUES: ReadonlySet = new Set([ + "true", + "false", + "1", + "0", + "yes", + "no", + "y", + "n", + "on", + "off", +]); + +// An action flag's own inline value must be one the boolean primitive +// accepts: `--version=true` (any accepted value, `false` included) serves the +// Version action, while `--version=bogus` fails the flag's own parse — no +// action is served, and the error keeps the agent JSON envelope. +function isBooleanActionOccurrence(arg: string, name: string): boolean { + if (arg === name) return true; + return arg.startsWith(`${name}=`) && BOOLEAN_FLAG_VALUES.has(arg.slice(name.length + 1)); +} + +// Inline spellings count for the skipped booleans with ANY value: the Version +// action is scanned on presence before `--wizard=bogus` ever parses, so even +// an invalid inline value there still renders the plain version line. function isRootBooleanFlag(arg: string): boolean { - return ( - arg === "--debug" || arg === "--experimental" || arg === "--yes" || arg === "--create-ticket" - ); + return ROOT_BOOLEAN_FLAGS.some((name) => isFlagOccurrence(arg, name)); } +// Deliberately bails at the first token that is not a known root flag +// (subcommand names included): the renderer serves the Version action at any +// depth, but several leaves declare their own `--version` (e.g. `db reset`), +// so ` --version` resolving conservatively to JSON is the accepted +// trade-off, ledgered with the walk-consolidation follow-up. function hasRootVersionRequest(args: ReadonlyArray): boolean { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === undefined || arg === "--") { return false; } - if (arg === "--version" || arg === "-v") { + if (isBooleanActionOccurrence(arg, "--version") || isBooleanActionOccurrence(arg, "-v")) { return true; } if (isRootValueFlag(arg)) { i++; continue; } + if (ROOT_BOOLEAN_FLAGS.includes(arg)) { + // The parser consumes a space-separated boolean literal too + // (`--wizard false --version` still serves Version), so skip it. + const next = args[i + 1]; + if (next !== undefined && BOOLEAN_FLAG_VALUES.has(next)) i++; + continue; + } if (isRootValueFlagWithInlineValue(arg) || isRootBooleanFlag(arg)) { continue; } @@ -147,7 +196,8 @@ function hasRootVersionRequest(args: ReadonlyArray): boolean { function hasHelpRequest(args: ReadonlyArray): boolean { for (const arg of args) { if (arg === "--") return false; - if (arg === "--help" || arg === "-h") return true; + if (isBooleanActionOccurrence(arg, "--help") || isBooleanActionOccurrence(arg, "-h")) + return true; } return false; } diff --git a/apps/cli/src/shared/cli/agent-output.unit.test.ts b/apps/cli/src/shared/cli/agent-output.unit.test.ts index 5f14370191..5d4b57d900 100644 --- a/apps/cli/src/shared/cli/agent-output.unit.test.ts +++ b/apps/cli/src/shared/cli/agent-output.unit.test.ts @@ -116,6 +116,57 @@ describe("resolveAgentOutputFormat", () => { Option.some("codex"), ), ).toBe("text"); + expect( + resolveAgentOutputFormatFromArgs(["--log-level", "error", "--version"], Option.some("codex")), + ).toBe("text"); + expect( + resolveAgentOutputFormatFromArgs(["--log-level=error", "--version"], Option.some("codex")), + ).toBe("text"); + // Version wins over Completions/Wizard (built-in action precedence), so + // these argv render the version line and must resolve to text. + expect( + resolveAgentOutputFormatFromArgs( + ["--completions", "bash", "--version"], + Option.some("codex"), + ), + ).toBe("text"); + expect(resolveAgentOutputFormatFromArgs(["--wizard", "--version"], Option.some("codex"))).toBe( + "text", + ); + expect( + resolveAgentOutputFormatFromArgs(["--wizard=true", "--version"], Option.some("codex")), + ).toBe("text"); + expect( + resolveAgentOutputFormatFromArgs(["--debug=false", "--version"], Option.some("codex")), + ).toBe("text"); + // An invalid inline value on a SKIPPED boolean still serves the action + // (presence is scanned before `--wizard=bogus` parses) — text. On the + // action flag ITSELF it fails that flag's own parse — no action, so the + // error keeps the agent JSON envelope. + expect( + resolveAgentOutputFormatFromArgs(["--wizard=bogus", "--version"], Option.some("codex")), + ).toBe("text"); + // The parser consumes a space-separated boolean literal too. + expect( + resolveAgentOutputFormatFromArgs(["--wizard", "false", "--version"], Option.some("codex")), + ).toBe("text"); + expect( + resolveAgentOutputFormatFromArgs(["--debug", "0", "--version"], Option.some("codex")), + ).toBe("text"); + // Boundary: a non-literal operand is NOT skipped — the walk bails + // conservatively even though the renderer still serves the version here + // (ledgered with the walk-consolidation follow-up). + expect( + resolveAgentOutputFormatFromArgs(["--wizard", "bogus", "--version"], Option.some("codex")), + ).toBe("json"); + expect(resolveAgentOutputFormatFromArgs(["--version=true"], Option.some("codex"))).toBe("text"); + expect(resolveAgentOutputFormatFromArgs(["--version=0"], Option.some("codex"))).toBe("text"); + expect(resolveAgentOutputFormatFromArgs(["-v=true"], Option.some("codex"))).toBe("text"); + expect(resolveAgentOutputFormatFromArgs(["--help=true"], Option.some("codex"))).toBe("text"); + expect(resolveAgentOutputFormatFromArgs(["--version=bogus"], Option.some("codex"))).toBe( + "json", + ); + expect(resolveAgentOutputFormatFromArgs(["--help=bogus"], Option.some("codex"))).toBe("json"); expect(resolveAgentOutputFormatFromArgs(["--help"], Option.some("codex"))).toBe("text"); expect( resolveAgentOutputFormatFromArgs(["db", "reset", "--version", "1"], Option.some("codex")), diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 0d305fcec1..bb06f94d41 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -101,14 +101,23 @@ export function lastExplicitLongFlagValue( /** * Value-taking long flags registered persistently on the Go root command - * (`apps/cli-go/cmd/root.go:324-333`: `--workdir`, `--network-id`, + * (`apps/cli-go/cmd/root.go:337-348`: `--workdir`, `--network-id`, * `--profile`, `--output`, `--dns-resolver`, `--agent`), plus the TS-only - * `--output-format` global (`shared/cli/global-flags.ts`) which the TS - * parser accepts on any subcommand. pflag lets any of these consume the - * following argv token, so a pflag-faithful scan must know them or it will - * miscount positionals on perfectly normal invocations like - * `sso update --workdir . `. Keep in sync with `globalFlagsWithValues` - * in `shared/cli/run.ts`. + * globals the TS parser accepts on any subcommand: `--output-format` + * (`shared/cli/global-flags.ts`) and the CLI library's built-in + * `--log-level`. pflag lets any of these consume the following argv token, + * so a pflag-faithful scan must know them or it will miscount positionals + * on perfectly normal invocations like `sso update --workdir . ` — or + * `sso update --log-level error `, which mis-reported + * `accepts 1 arg(s), received 2` before `log-level` was listed here + * (issue #6482). `--completions`, the other value-taking built-in, is + * deliberately absent: every consumer of this set runs inside a command + * handler, which a parsed `--completions` never reaches (its print-and-exit + * action runs first); only the pre-parse scanners (`globalFlagsWithValues` + * in `shared/cli/run.ts`, the predicates in `shared/cli/agent-output.ts`) + * need it. No manual sync is required: both pre-parse consumers read + * `GLOBAL_VALUE_FLAG_TOKENS` below, which is derived from this set plus + * `--completions`. */ export const PERSISTENT_VALUE_FLAG_NAMES: ReadonlySet = new Set([ "workdir", @@ -118,16 +127,32 @@ export const PERSISTENT_VALUE_FLAG_NAMES: ReadonlySet = new Set([ "dns-resolver", "agent", "output-format", + "log-level", ]); /** * Shorthands of the persistent value-taking flags above (`-o` → `--output`, - * `cmd/root.go:330`), mapped to their canonical long names. + * `cmd/root.go:344`), mapped to their canonical long names. */ export const PERSISTENT_VALUE_FLAG_SHORTHANDS: ReadonlyMap = new Map([ ["o", "output"], ]); +/** + * Token-keyed view of every value-taking global the TS parser accepts: + * `PERSISTENT_VALUE_FLAG_NAMES` as `--` tokens, its shorthands, and the + * `--completions` built-in (needed by pre-parse scanners only — see above). + * Derived rather than hand-copied so the registries cannot drift apart, which + * is how issue #6482 happened. Consumed by `run.ts`'s argv scanners and + * `agent-output.ts`'s format predicates; pinned to its exact expected + * contents in `cobra-flag-groups.unit.test.ts`. + */ +export const GLOBAL_VALUE_FLAG_TOKENS: ReadonlySet = new Set([ + ...[...PERSISTENT_VALUE_FLAG_NAMES].map((name) => `--${name}`), + ...[...PERSISTENT_VALUE_FLAG_SHORTHANDS.keys()].map((short) => `-${short}`), + "--completions", +]); + export interface PflagArgvScanSpec { /** * Every value-taking (non-boolean) long flag reachable when this command diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index a502e5df3d..592abbbca9 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { cobraMutuallyExclusiveErrorMessage, explicitBooleanLongFlag, + GLOBAL_VALUE_FLAG_TOKENS, hasExplicitLongFlag, lastExplicitLongFlagValue, PERSISTENT_VALUE_FLAG_NAMES, @@ -373,7 +374,7 @@ describe("pflagArgvScan", () => { test("a persistent global value flag consumes its value token", () => { // `--workdir .` must not count `.` as a positional — pflag consumes it - // (root persistent flags, `cmd/root.go:324-333`). + // (root persistent flags, `cmd/root.go:337-348`). const scan = pflagArgvScan( ["sso", "update", "--workdir", ".", "id", "--profile", "staging"], SSO_UPDATE_PATH, @@ -382,6 +383,32 @@ describe("pflagArgvScan", () => { expect(scan.positionals).toEqual(["id"]); }); + test("the built-in --log-level global consumes its value token", () => { + // The real parser consumes `error`, so the scan must too or + // `sso update --log-level error ` mis-reports + // `accepts 1 arg(s), received 2` (issue #6482). + const scan = pflagArgvScan( + ["sso", "update", "--log-level", "error", "id"], + SSO_UPDATE_PATH, + SPEC, + ); + expect(scan.occurrences.get("log-level")).toEqual(["error"]); + expect(scan.positionals).toEqual(["id"]); + }); + + test("a pre-path --log-level keeps the scan anchored instead of falling back unscoped", () => { + // Unregistered, the anchor walk hit `error` as a stray operand and + // fell back to the unanchored scan, skipping the arity re-count. + const scan = pflagArgvScan( + ["--log-level", "error", "sso", "update", "id"], + SSO_UPDATE_PATH, + SPEC, + ); + expect(scan.anchored).toBe(true); + expect(scan.prePathOccurrences.get("log-level")).toEqual(["error"]); + expect(scan.positionals).toEqual(["id"]); + }); + test("a bare slice flag consumes a global flag token, orphaning its value", () => { // Binary-verified Go behaviour: `--domains --profile staging ` // arity-errors because `staging` becomes positional. @@ -579,6 +606,29 @@ describe("pflagArgvScan", () => { }); }); +describe("GLOBAL_VALUE_FLAG_TOKENS", () => { + // The derived token registry feeds run.ts's argv scanners and + // agent-output.ts's format predicates. Pinning its exact contents makes an + // accidental edit to the derivation (or its inputs) fail loudly — the + // silent-drift trap behind issue #6482. + test("holds exactly the value-taking global tokens the TS parser accepts", () => { + expect(GLOBAL_VALUE_FLAG_TOKENS).toEqual( + new Set([ + "--workdir", + "--network-id", + "--profile", + "--output", + "--dns-resolver", + "--agent", + "--output-format", + "--log-level", + "-o", + "--completions", + ]), + ); + }); +}); + describe("cobraMutuallyExclusiveErrorMessage", () => { test("byte-matches cobra's validateExclusiveFlagGroups template", () => { expect( diff --git a/apps/cli/src/shared/cli/global-flags.ts b/apps/cli/src/shared/cli/global-flags.ts index 414f7aa2c3..c1fa32d0b3 100644 --- a/apps/cli/src/shared/cli/global-flags.ts +++ b/apps/cli/src/shared/cli/global-flags.ts @@ -4,13 +4,14 @@ import { Flag, GlobalFlag } from "effect/unstable/cli"; * The TS-only `--output-format` global (no Go counterpart), accepted on any * subcommand. * - * It takes a value, so its token is registered in `globalFlagsWithValues` - * (`shared/cli/run.ts`) and its name in `PERSISTENT_VALUE_FLAG_NAMES` - * (`shared/cli/cobra-flag-groups.ts`) — any value-taking global added here - * needs both, or the raw-argv pflag scanners that run for + * It takes a value, so its name is registered in + * `PERSISTENT_VALUE_FLAG_NAMES` (`shared/cli/cobra-flag-groups.ts`) — the + * pre-parse scanners derive their token set from it + * (`GLOBAL_VALUE_FLAG_TOKENS`), so that one edit covers any value-taking + * global added here. Without it, the raw-argv scanners that run for * `--help`/`--version`/bare-group invocations will not consume its following * token. See `LEGACY_GLOBAL_FLAGS` (`shared/legacy/global-flags.ts`) for what - * silently breaks when they drift. + * silently breaks when a flag is missing from the shared registry. */ export const OutputFormatFlag = GlobalFlag.setting("output-format")({ flag: Flag.choice("output-format", ["text", "json", "stream-json"]).pipe( diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index e6e7271825..e28a1d5366 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -50,6 +50,7 @@ import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts"; import type { TelemetryRuntime } from "../telemetry/runtime.service.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; +import { GLOBAL_VALUE_FLAG_TOKENS } from "./cobra-flag-groups.ts"; import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts"; import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; @@ -87,24 +88,25 @@ type AllowedRunCliServices = | "effect/unstable/cli/GlobalFlag/linked" | "effect/unstable/cli/GlobalFlag/local"; -// Global flags that consume the following argv token as their value. Keep this in -// sync with the value-taking global flags defined in `shared/cli/global-flags.ts` -// and `shared/legacy/global-flags.ts` (both point back here), and with the -// name-keyed `PERSISTENT_VALUE_FLAG_NAMES` in `shared/cli/cobra-flag-groups.ts`: -// a value flag missing here would make `extractCommandPath` mistake its value for -// a command-path segment, and would leave the flag's following token unconsumed +// Global flags that consume the following argv token as their value — a value +// flag missing here would make `extractCommandPath` mistake its value for a +// command-path segment, and would leave the flag's following token unconsumed // for every scanner below — silently mis-resolving `--workdir` for the bare -// space-separated spelling. -const globalFlagsWithValues = new Set([ - "--output-format", - "--output", - "-o", - "--profile", - "--workdir", - "--network-id", - "--dns-resolver", - "--agent", -]); +// space-separated spelling, or missing the root `--version` behind +// `--completions bash`. Derived from `PERSISTENT_VALUE_FLAG_NAMES` (see +// `GLOBAL_VALUE_FLAG_TOKENS`) so the registries cannot drift apart again +// (issue #6482). +// +// DELIBERATE MODEL SPLIT: the scanners below keep pflag-style semantics for +// BOOLEAN globals — a bare `--debug` never consumes a following token here — +// while the shipped parser also consumes a space-separated boolean literal +// (`--debug false`), which `agent-output.ts`'s format walk mirrors. The +// residual divergence only steers the upgrade-notice base-dir/force-fetch +// choice and the signal-wrapper selection for spellings like +// `--debug false --version`, predates the issue #6482 fixes, and is +// deliberately left with the walk-consolidation follow-up rather than +// widened into this scanner family piecemeal. +const globalFlagsWithValues: ReadonlySet = GLOBAL_VALUE_FLAG_TOKENS; // Commands that run their own foreground signal loop (serve/start daemons) and must // NOT be wrapped in the global signal-interrupt handler, which would otherwise race @@ -209,7 +211,19 @@ function isFlagOccurrence(token: string, name: string): boolean { return token === name || token.startsWith(`${name}=`); } -/** `strconv.ParseBool`'s true spellings — how pflag reads a boolean flag's `=`. An invalid value fails Go's whole parse, so a run never reaches the notice and reading it as false here is harmless. */ +/** + * `strconv.ParseBool`'s true spellings — a TRUTHINESS set for the + * pflag-modeled `--version=` resolution below. Answers a different + * question than `BOOLEAN_FLAG_VALUES` (`agent-output.ts`), which asks whether + * the shipped parser ACCEPTS the value at all — that parser serves the + * Version action for any accepted value, `--version=no` included, so the two + * sets must not be merged. The residual divergence runs both ways — an + * accepted-but-not-ParseBool-true spelling (`--version=no`) resolves + * `version=false` here while the renderer still serves the version, and a + * ParseBool-true spelling the parser rejects (`--version=t`) never serves + * anything — but it only steers the upgrade-notice scans and predates the + * issue #6482 fixes. + */ const PFLAG_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** @@ -298,9 +312,13 @@ export function hasRootVersionFlag( * check before `preRun`), and the one input Go would instead run (a runnable * leaf under `--help=false`) is a spelling the vendored effect CLI serves * help for anyway. The version flag resolves pflag-style, last value wins: a - * true value serves the version built-in, while `--version=false ` runs - * the leaf normally — `ChangeWorkDir` included — and only a bare invocation - * falls back to the non-runnable root's help. + * true value counts as the version built-in, `--version=false ` counts + * as running the leaf — `ChangeWorkDir` included — and only a bare + * invocation falls back to the non-runnable root's help. That is this + * function's MODEL, not the shipped renderer's behavior: the parser serves + * the Version action for any accepted value, `--version=no` included (see + * `PFLAG_BOOL_TRUE`'s doc above for the deliberate split — only the + * upgrade-notice checks ride on this resolution). */ export function hasRootHelpOrVersionFlag( args: ReadonlyArray, diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 4ba0f08fe7..9f328a85f6 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -39,6 +39,17 @@ describe("extractCommandPath", () => { ).toEqual(["functions", "serve"]); }); + it("skips the built-in --log-level flag and its value", () => { + expect(extractCommandPath(["--log-level", "error", "functions", "serve"])).toEqual([ + "functions", + "serve", + ]); + }); + + it("skips the built-in --completions flag and its shell value", () => { + expect(extractCommandPath(["--completions", "bash", "--version"])).toEqual([]); + }); + it("treats --flag=value as a single token", () => { expect(extractCommandPath(["--output-format=json", "functions", "serve"])).toEqual([ "functions", @@ -525,6 +536,10 @@ describe("hasRootVersionFlag", () => { [["--profile", "-v"], false], [["--profile=x", "-v"], false], [["-o", "-v"], false], + // `--completions` consumes its shell value, so the root `--version` + // behind it stays a version request (issue #6482 built-in registration). + [["--completions", "bash", "--version"], true], + [["--completions", "--version"], false], [["db", "reset", "--version", "20240101000000"], false], [["migration", "squash", "--version", "x"], false], [["branches", "-v"], false], @@ -533,4 +548,8 @@ describe("hasRootVersionFlag", () => { ])("%j -> %s", (args, expected) => { expect(hasRootVersionFlag(args as ReadonlyArray)).toBe(expected); }); + + it("hasRootHelpOrVersionFlag sees the root --version behind --completions and its value", () => { + expect(hasRootHelpOrVersionFlag(["--completions", "bash", "--version"])).toBe(true); + }); }); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index daae06aa79..573296da84 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -20,7 +20,7 @@ import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./leg // // Every description string below is copied VERBATIM (including Go's own // lowercase, no-trailing-period house style for root persistent flags) from -// `apps/cli-go/cmd/root.go:324-333` — this text is directly user-visible now +// `apps/cli-go/cmd/root.go:337-348` — this text is directly user-visible now // that native shell completion (CLI-1965) surfaces it in `__complete` // candidate descriptions, where a prior Go-binary passthrough used to emit // Go's own text byte-for-byte; before that, this only reached the TS-native @@ -117,21 +117,26 @@ export const LegacyAgentFlag = GlobalFlag.setting("agent")({ /** * Every global/persistent flag declared above, mirroring the set Go registers on - * the root command (`apps/cli-go/cmd/root.go:344-354`). + * the root command (`apps/cli-go/cmd/root.go:337-348`). * - * Adding a VALUE-taking flag here also means registering its token in - * `globalFlagsWithValues` (`shared/cli/run.ts`) and its name in - * `PERSISTENT_VALUE_FLAG_NAMES` (`shared/cli/cobra-flag-groups.ts`). Those two - * registries feed raw-argv pflag scanners that must run for `--help`/`--version` - * /bare-group invocations, which cobra serves before `PersistentPreRunE` and so - * never expose parsed flag values to read instead. Neither registry is derived - * from this list, and drift fails silently rather than loudly: an unregistered - * value flag does not consume its following token, so `supabase --new-flag + * Adding a VALUE-taking flag here also means adding its name to + * `PERSISTENT_VALUE_FLAG_NAMES` (`shared/cli/cobra-flag-groups.ts`): the + * handler-side pflag scans read it directly, and the pre-parse scanners + * (`globalFlagsWithValues` in `shared/cli/run.ts`, the `agent-output.ts` + * predicates) derive their token set from it (`GLOBAL_VALUE_FLAG_TOKENS`), + * so that one edit covers them all. The same obligation covers the CLI + * library's own value-taking built-ins (`--log-level` — issue #6482; + * `--completions` is scoped per `PERSISTENT_VALUE_FLAG_NAMES`'s doc). The + * pre-parse scanners must run even for `--help`/`--version`/bare-group + * invocations, which cobra serves before `PersistentPreRunE` and so never + * expose parsed flag values to read instead. A flag missed in the shared + * registry still fails silently rather than loudly: an unregistered value + * flag does not consume its following token, so `supabase --new-flag * --workdir other ` makes the upgrade notice read/write * `other/supabase/.temp/cli-latest`, where Go — which lets `--new-flag` eat - * `--workdir` — resolves against the cwd. Only the bare space-separated spelling - * diverges (`--new-flag=x --workdir other` agrees), which is what makes it easy - * to miss. + * `--workdir` — resolves against the cwd. Only the bare space-separated + * spelling diverges (`--new-flag=x --workdir other` agrees), which is what + * makes it easy to miss. */ export const LEGACY_GLOBAL_FLAGS = [ LegacyOutputFlag, From d3961dd6ce305d1bad899421849fc5804e61eb11 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 7 Sep 2026 19:34:52 +0000 Subject: [PATCH 07/57] feat(stack): rewrite managed local stack runtime (#6440) ## Summary Rewrites `@supabase/stack` around one managed-only runtime shared by strict native and container execution modes. The package owns durable configuration and secrets, sticky ports, artifact preparation, lifecycle arbitration, per-service eager or lazy activation, service migrations, ingress, retained and live logs, and exact resource cleanup. A stopped stack has no resident supervisor or runtime resources: status and retained logs come from durable state, while a later start creates a fresh owner and performs clean recovery. PostgreSQL 17 is the only eager service by default; other enabled services activate through stack ingress, and callers can configure eager activation. Dependency-ready workloads start concurrently. Docker-compatible engines, including Podman, share the same container path. Enabled lazy services prepare their artifacts in the background after startup by default. `preparation: "on-demand"` retains full lazy downloading, while explicit `prepare()` remains available in either mode. Foreground activation prepares its dependency closure concurrently and shares in-flight downloads with background preparation. All selected workload downloads can run concurrently. Native archives are streamed to disk while hashing, verified before extraction, and decompressed without retaining whole archives in memory. Runtime cleanup cancels unfinished transfers and retains completed cache entries. Status exposes artifact preparation separately from service readiness, and explicit preparation reports progress through `onProgress`. Edge Functions are served through the stack-owned Edge Runtime for package consumers. The package runs independently of the CLI and accepts normalized `StackConfig` values without reading `config.toml`. All CLI integration, including `supabase functions serve` and lifecycle commands under `experimental start`, belongs to M5 in separate PRs. Existing CLI Functions behavior is preserved. The deleted `next` CLI was a disposable proving ground and is not part of this PR. Service preparation now lives in the published runtime artifacts: PostgreSQL owns first boot and bundled migrations, service helpers own migration and Pooler tenant provisioning, and Node services expose public launchers shared with their images. The stack supplies instance settings and sequences those commands. Default images use the configured slim-services tags or digests, including the artifacts from [slim-services #299](https://github.com/supabase/slim-services/pull/299) and the BEAM versions republished after [slim-services #302](https://github.com/supabase/slim-services/pull/302). Native downloads are checked against the checksum published with the same release. BEAM launchers own the shared runtime defaults in native and container modes; Vector configuration and Edge Functions bootstrap remain stack-owned. Unix control sockets bind inside owner-private directories. Container mount paths preserve CSV special characters, and default state paths use the user home directory when `HOME` is absent. Obsolete process-compose integration and superseded runtime helpers are removed. Supersedes #6385 --- .github/workflows/test.yml | 45 +- .gitignore | 3 + .gitmodules | 3 - .oxlintrc.json | 2 - .repos/process-compose | 1 - AGENTS.md | 5 +- CONTRIBUTING.md | 4 +- apps/cli/AGENTS.md | 2 +- apps/cli/package.json | 1 - .../db-bootstrap/health-check.ts | 2 +- .../db-bootstrap/reset-local-database.ts | 2 +- .../db-bootstrap/shadow-cache.ts | 2 +- .../db-bootstrap/shadow-database.ts | 6 +- .../db-bootstrap/start-database.ts | 2 +- .../db-bootstrap/start-local-database.ts | 4 +- .../legacy-config-validate.ts | 2 +- .../legacy-db-config.toml-read.ts | 4 +- .../src/command-internal/legacy-db-image.ts | 10 - .../legacy-docker-lifecycle.ts | 2 +- .../legacy-docker-remove-all.ts | 14 +- .../legacy-edge-runtime-script.service.ts | 2 +- .../legacy-local-config-values.ts | 6 +- .../legacy-password-requirements.ts | 2 +- .../src/command-internal/legacy-size-units.ts | 40 +- .../legacy-storage-credentials.ts | 2 +- .../legacy-workdir-project.ts | 2 +- .../src/commands/config/pull/pull.command.ts | 4 +- .../src/commands/config/pull/pull.format.ts | 2 +- .../cli/src/commands/config/pull/pull.plan.ts | 6 +- .../src/commands/config/pull/pull.scope.ts | 8 +- .../src/commands/config/push/push.format.ts | 4 +- .../db/schema/declarative/declarative.flow.ts | 4 +- .../declarative/declarative.former-default.ts | 2 +- .../legacy-pgdelta-declarative-shadow-prep.ts | 2 +- .../db/shared/legacy-pgdelta-engine.layer.ts | 4 +- .../shared/legacy-pgdelta-engine.service.ts | 2 +- .../db/shared/legacy-pgdelta-files.ts | 2 +- .../legacy-pgdelta-next-adapter.service.ts | 2 - .../db/shared/legacy-pgdelta.write.ts | 2 +- .../src/commands/domains/domains.errors.ts | 4 +- .../commands/encryption/encryption.errors.ts | 4 +- .../experimental/workers/workers.shared.ts | 2 +- .../deploy/deploy.integration.test.ts | 2 +- .../download/download.integration.test.ts | 28 +- .../src/commands/functions/new/new.handler.ts | 2 +- .../gen/types/types.integration.test.ts | 2 +- .../edge-runtime.service.integration.test.ts | 2 +- .../start/start.slim-images.e2e.test.ts | 31 +- apps/cli/src/docs/legacy-docs-spec.ts | 4 +- apps/cli/src/shared/cli/run.ts | 5 - apps/cli/src/shared/config/supabase-home.ts | 8 +- .../functions/functions-docker.unit.test.ts | 2 +- apps/cli/src/shared/functions/serve.ts | 2 +- apps/cli/src/shared/output/normalize-error.ts | 137 - .../output/normalize-error.unit.test.ts | 179 +- .../src/shared/services/services.shared.ts | 2 +- apps/cli/src/shared/services/slim-images.ts | 51 +- apps/cli/src/shared/stack-constants.ts | 65 + .../error-actionability-coverage.unit.test.ts | 545 +--- .../shared/telemetry/error-actionability.ts | 430 +-- .../error-actionability.unit.test.ts | 1224 +------ apps/cli/tests/e2e-global-setup.ts | 23 - .../tests/helpers/child-process-spawner.ts | 0 apps/cli/tests/helpers/mocks.ts | 170 +- apps/cli/tests/helpers/stack-e2e-cleanup.ts | 7 - apps/cli/vitest.config.ts | 1 - ...7-simplified-managed-stack-architecture.md | 279 +- .../2026-08-16-stack-simplification-design.md | 2 +- knip.json | 8 +- package.json | 4 +- packages/process-compose/AGENTS.md | 23 - packages/process-compose/CLAUDE.md | 1 - packages/process-compose/README.md | 47 - packages/process-compose/docs/architecture.md | 314 -- packages/process-compose/package.json | 29 - .../process-compose/src/DependencyGraph.ts | 153 - .../src/DependencyGraph.unit.test.ts | 220 -- packages/process-compose/src/HealthProbe.ts | 112 - .../src/HealthProbe.unit.test.ts | 491 --- packages/process-compose/src/LogBuffer.ts | 105 - .../src/LogBuffer.unit.test.ts | 178 -- .../src/Orchestrator.integration.test.ts | 565 ---- packages/process-compose/src/Orchestrator.ts | 982 ------ .../src/Orchestrator.unit.test.ts | 2550 --------------- .../process-compose/src/RestartClosure.ts | 37 - .../src/RestartClosure.unit.test.ts | 72 - .../process-compose/src/RestartDecision.ts | 53 - .../src/RestartDecision.unit.test.ts | 72 - packages/process-compose/src/ServiceDef.ts | 121 - packages/process-compose/src/ServiceState.ts | 38 - .../src/ServiceState.unit.test.ts | 35 - .../process-compose/src/ServiceTransition.ts | 186 -- .../src/ServiceTransition.unit.test.ts | 508 --- packages/process-compose/src/Supervisor.ts | 46 - .../src/SupervisorRuntime.unit.test.ts | 431 --- packages/process-compose/src/errors.ts | 25 - .../process-compose/src/errors.unit.test.ts | 37 - packages/process-compose/src/index.ts | 47 - .../src/supervisor-protocol.ts | 56 - .../process-compose/src/supervisor-runtime.ts | 428 --- packages/process-compose/tsconfig.json | 7 - packages/process-compose/vitest.config.ts | 29 - packages/stack/README.md | 173 +- packages/stack/docs/architecture.md | 426 --- packages/stack/docs/effect-platform-gaps.md | 154 - .../stack/docs/resource-leak-mitigations.md | 111 - packages/stack/docs/service-versioning.md | 181 -- packages/stack/package.json | 40 +- packages/stack/scripts/migrate-fast.sh | 90 - .../scripts/sync-versions-from-dockerfile.ts | 131 - packages/stack/src/ApiProxy.ts | 429 --- packages/stack/src/ApiProxy.unit.test.ts | 563 ---- .../src/BinaryResolver.integration.test.ts | 844 ----- packages/stack/src/BinaryResolver.ts | 914 ------ .../stack/src/BinaryResolver.unit.test.ts | 48 - packages/stack/src/CleanupTargets.ts | 3 - .../src/ContainerRuntime.integration.test.ts | 106 - packages/stack/src/ContainerRuntime.ts | 94 - packages/stack/src/ControlHttpReader.ts | 198 -- packages/stack/src/ControlStopClient.ts | 76 - packages/stack/src/DaemonProtocol.ts | 77 - .../HttpTransportClient.integration.test.ts | 142 - packages/stack/src/HttpTransportClient.ts | 150 - packages/stack/src/JwtGenerator.ts | 40 - packages/stack/src/JwtGenerator.unit.test.ts | 48 - packages/stack/src/LocalStack.ts | 1238 -------- packages/stack/src/Platform.ts | 50 - packages/stack/src/Platform.unit.test.ts | 76 - .../src/PortAllocator.integration.test.ts | 250 -- packages/stack/src/PortAllocator.ts | 760 ----- packages/stack/src/PortCatalog.ts | 229 -- .../RemoteStack.rpc.bun.integration.test.ts | 88 - .../src/RemoteStack.rpc.integration.test.ts | 1349 -------- packages/stack/src/RemoteStack.ts | 408 --- packages/stack/src/ServiceActivation.ts | 105 - .../stack/src/ServiceActivation.unit.test.ts | 72 - packages/stack/src/ServiceCatalog.ts | 345 -- packages/stack/src/ServiceExclusions.ts | 34 - packages/stack/src/ServiceName.ts | 15 - packages/stack/src/ServicePorts.ts | 36 - packages/stack/src/Stack.ts | 239 -- packages/stack/src/Stack.unit.test.ts | 2100 ------------ packages/stack/src/StackBuilder.ts | 684 ---- packages/stack/src/StackBuilder.unit.test.ts | 564 ---- packages/stack/src/StackConfig.ts | 345 -- packages/stack/src/StackConfig.unit.test.ts | 95 - .../StackConfigResolver.policy.unit.test.ts | 127 - packages/stack/src/StackConfigResolver.ts | 822 ----- packages/stack/src/StackIdentity.ts | 42 - packages/stack/src/StackIdentity.unit.test.ts | 12 - packages/stack/src/StackPreparation.ts | 396 --- .../stack/src/StackRpc.integration.test.ts | 32 - packages/stack/src/StackRpc.ts | 268 -- .../src/StackRpcHandlers.integration.test.ts | 396 --- packages/stack/src/StackRpcHandlers.ts | 138 - packages/stack/src/StackServiceState.ts | 41 - .../stack/src/StackServiceState.unit.test.ts | 27 - packages/stack/src/StackStateProjection.ts | 85 - .../src/StackStateProjection.unit.test.ts | 86 - ...upervisorControlServer.integration.test.ts | 41 - packages/stack/src/SupervisorControlServer.ts | 90 - packages/stack/src/SupervisorProtocol.ts | 63 - .../src/SupervisorSession.integration.test.ts | 315 -- packages/stack/src/SupervisorSession.ts | 330 -- ...pervisorUpgradeRestart.integration.test.ts | 447 --- .../stack/src/SupervisorUpgradeRestart.ts | 427 --- packages/stack/src/bun.ts | 67 - packages/stack/src/cleanup.ts | 108 - packages/stack/src/cleanup.unit.test.ts | 72 - .../compiled-supervisor.integration.test.ts | 397 --- packages/stack/src/control/ControlServer.ts | 810 +++++ packages/stack/src/control/FrameCodec.ts | 124 + .../stack/src/control/MaintenanceProtocol.ts | 171 + packages/stack/src/control/StackRpc.ts | 55 + .../control-transport.integration.test.ts | 795 +++++ .../stack/src/createStack.integration.test.ts | 264 -- packages/stack/src/createStack.ts | 292 -- packages/stack/src/createStack.unit.test.ts | 398 --- packages/stack/src/daemon-bun.ts | 27 - packages/stack/src/daemon-node.ts | 35 - packages/stack/src/discovery.ts | 228 -- packages/stack/src/effect-bun.ts | 93 - .../src/effect-delete.integration.test.ts | 56 - packages/stack/src/effect-node.ts | 93 - packages/stack/src/effect.ts | 168 +- .../stack/src/entrypoints/supervisor-node.ts | 145 + packages/stack/src/error-code.ts | 16 - packages/stack/src/error-code.unit.test.ts | 20 - packages/stack/src/errors.ts | 206 -- packages/stack/src/functions.ts | 253 -- packages/stack/src/functions.unit.test.ts | 425 --- .../stack/src/functions/FunctionsBootstrap.ts | 109 + .../functions-bootstrap.integration.test.ts | 67 + .../stack/src/functions/serve-main-bundler.ts | 27 + .../functions/serve-main-bundler.unit.test.ts | 15 + .../stack/src/functions/serve-main-deps.ts | 54 + .../serve-main-resolver.integration.test.ts | 388 +++ .../src/functions/serve-main-resolver.ts | 243 ++ packages/stack/src/functions/serve.main.ts | 290 ++ packages/stack/src/gateway/Gateway.ts | 165 + packages/stack/src/gateway/HttpGateway.ts | 657 ++++ packages/stack/src/gateway/RouteCatalog.ts | 311 ++ packages/stack/src/gateway/TcpGateway.ts | 240 ++ .../src/gateway/gateway.integration.test.ts | 1099 +++++++ .../gateway/route-catalog.integration.test.ts | 127 + .../startup-transports.integration.test.ts | 370 +++ packages/stack/src/identity/FolderIdentity.ts | 30 + packages/stack/src/identity/GitIdentity.ts | 188 ++ packages/stack/src/identity/Identity.ts | 122 + .../src/identity/identity.integration.test.ts | 297 ++ packages/stack/src/index.ts | 89 +- .../stack/src/internal/supervisor-process.ts | 27 + packages/stack/src/layers.ts | 287 -- packages/stack/src/managed-bun.ts | 29 - .../src/managed-control.integration.test.ts | 1042 ------ .../managed-environment.integration.test.ts | 181 -- ...aged-manager-lifecycle.integration.test.ts | 797 ----- .../managed-manager-ports.integration.test.ts | 514 --- ...naged-manager-projects.integration.test.ts | 279 -- ...naged-manager-recovery.integration.test.ts | 427 --- ...aged-manager-worktrees.integration.test.ts | 68 - packages/stack/src/managed-node.ts | 37 - packages/stack/src/managed-paths.unit.test.ts | 204 -- .../src/managed-store.integration.test.ts | 271 -- packages/stack/src/managed.ts | 81 - .../managed/atomic-claim.integration.test.ts | 126 - packages/stack/src/managed/atomic-claim.ts | 144 - packages/stack/src/managed/control.ts | 805 ----- packages/stack/src/managed/document.ts | 163 - packages/stack/src/managed/environment.ts | 325 -- packages/stack/src/managed/failure.ts | 32 - packages/stack/src/managed/git-identity.ts | 45 - .../stack/src/managed/git.integration.test.ts | 240 -- packages/stack/src/managed/git.ts | 1058 ------- packages/stack/src/managed/identity.ts | 402 --- packages/stack/src/managed/ids.ts | 19 - packages/stack/src/managed/lifecycle.ts | 314 -- packages/stack/src/managed/manager.ts | 1050 ------ .../stack/src/managed/manager.unit.test.ts | 48 - packages/stack/src/managed/model.ts | 232 -- packages/stack/src/managed/paths.ts | 194 -- packages/stack/src/managed/port-intent.ts | 50 - .../src/managed/port-intent.unit.test.ts | 30 - packages/stack/src/managed/port-plan.ts | 139 - .../stack/src/managed/port-plan.unit.test.ts | 116 - packages/stack/src/managed/store.ts | 236 -- packages/stack/src/model/CapabilityModule.ts | 110 + packages/stack/src/model/Compiler.ts | 707 +++++ packages/stack/src/model/DatabaseBootstrap.ts | 123 + packages/stack/src/model/ExecutionPlan.ts | 311 ++ packages/stack/src/model/WorkloadCatalog.ts | 257 ++ .../stack/src/model/capabilities/analytics.ts | 52 + .../model/capabilities/auth-third-party.ts | 122 + packages/stack/src/model/capabilities/auth.ts | 427 +++ .../stack/src/model/capabilities/database.ts | 168 + .../stack/src/model/capabilities/functions.ts | 94 + .../stack/src/model/capabilities/index.ts | 10 + packages/stack/src/model/capabilities/mail.ts | 34 + .../stack/src/model/capabilities/pooler.ts | 46 + .../stack/src/model/capabilities/realtime.ts | 44 + packages/stack/src/model/capabilities/rest.ts | 49 + .../stack/src/model/capabilities/storage.ts | 145 + .../stack/src/model/capabilities/studio.ts | 36 + .../src/model/catalog.integration.test.ts | 309 ++ .../src/model/compiler.integration.test.ts | 836 +++++ .../database-bootstrap.integration.test.ts | 206 ++ .../src/node-entrypoint.integration.test.ts | 17 - packages/stack/src/node.ts | 74 - packages/stack/src/paths.ts | 11 - .../src/platform-bun.integration.test.ts | 377 --- packages/stack/src/platform-bun.ts | 247 -- .../src/platform-node.integration.test.ts | 318 -- packages/stack/src/platform-node.ts | 195 -- packages/stack/src/prefetch.ts | 40 - packages/stack/src/prefetch.unit.test.ts | 516 --- .../stack/src/preparation/ArtifactStore.ts | 844 +++++ packages/stack/src/preparation/Integrity.ts | 70 + .../stack/src/preparation/RuntimeArtifacts.ts | 280 ++ .../src/preparation/SlimServicesSource.ts | 421 +++ .../preparation/artifacts.integration.test.ts | 728 +++++ .../runtime-artifacts.integration.test.ts | 387 +++ .../slim-services.integration.test.ts | 511 +++ packages/stack/src/public/Capability.ts | 38 + packages/stack/src/public/Config.ts | 127 + packages/stack/src/public/Credentials.ts | 67 + packages/stack/src/public/EffectStack.ts | 1139 +++++++ packages/stack/src/public/Errors.ts | 328 ++ packages/stack/src/public/Logs.ts | 32 + packages/stack/src/public/PromiseStack.ts | 191 ++ packages/stack/src/public/Runtime.ts | 19 + packages/stack/src/public/StackId.ts | 17 + packages/stack/src/public/Status.ts | 169 + packages/stack/src/public/Testing.ts | 251 ++ .../public/effect-stack.integration.test.ts | 2337 ++++++++++++++ packages/stack/src/public/index.ts | 20 + .../src/public/promise.integration.test.ts | 286 ++ .../public/public-model.integration.test.ts | 75 + .../src/public/testing.integration.test.ts | 514 +++ .../stack/src/public/whole-stack.e2e.test.ts | 1527 +++++++++ packages/stack/src/runtime/ContainerEngine.ts | 886 ++++++ .../src/runtime/ContainerEngineResolver.ts | 50 + .../stack/src/runtime/ContainerRuntime.ts | 1048 ++++++ .../src/runtime/DatabaseBootstrapCatalog.ts | 52 + packages/stack/src/runtime/DockerEngine.ts | 101 + packages/stack/src/runtime/NativeProcess.ts | 198 ++ packages/stack/src/runtime/NativeRuntime.ts | 604 ++++ packages/stack/src/runtime/PodmanEngine.ts | 102 + .../src/runtime/PostgresDatabaseSession.ts | 216 ++ .../stack/src/runtime/ProductionRuntime.ts | 1097 +++++++ packages/stack/src/runtime/ReadinessProbe.ts | 211 ++ packages/stack/src/runtime/RuntimeDriver.ts | 58 + packages/stack/src/runtime/RuntimeEnvFile.ts | 136 + .../stack/src/runtime/RuntimeInputOwner.ts | 559 ++++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 1449 +++++++++ .../container-runtime.integration.test.ts | 2816 +++++++++++++++++ ...base-bootstrap-catalog.integration.test.ts | 84 + packages/stack/src/runtime/native-launcher.ts | 242 ++ .../native-runtime.integration.test.ts | 1074 +++++++ ...tgres-database-session.integration.test.ts | 266 ++ .../production-runtime.integration.test.ts | 2804 ++++++++++++++++ .../readiness-probe.integration.test.ts | 264 ++ .../runtime-env-file.integration.test.ts | 99 + .../runtime-input-owner.integration.test.ts | 754 +++++ .../workload-runtime.integration.test.ts | 1368 ++++++++ packages/stack/src/services/analytics.ts | 87 - packages/stack/src/services/auth.ts | 98 - packages/stack/src/services/docker-cleanup.ts | 40 - .../stack/src/services/edge-runtime-main.ts | 297 -- packages/stack/src/services/edge-runtime.ts | 127 - packages/stack/src/services/health-budgets.ts | 137 - .../src/services/health-budgets.unit.test.ts | 139 - packages/stack/src/services/imgproxy.ts | 49 - packages/stack/src/services/mailpit.ts | 42 - packages/stack/src/services/nofile-limit.ts | 59 - .../src/services/nofile-limit.unit.test.ts | 74 - packages/stack/src/services/pgmeta.ts | 49 - packages/stack/src/services/pooler.ts | 120 - packages/stack/src/services/postgres-init.ts | 235 -- packages/stack/src/services/postgres.ts | 188 -- packages/stack/src/services/postgrest.ts | 82 - packages/stack/src/services/realtime.ts | 74 - packages/stack/src/services/service-utils.ts | 120 - .../stack/src/services/services.unit.test.ts | 691 ---- packages/stack/src/services/storage.ts | 90 - packages/stack/src/services/studio.ts | 76 - packages/stack/src/services/vector.ts | 131 - .../stack/src/services/vector.unit.test.ts | 122 - packages/stack/src/stackHandle.ts | 55 - .../stack/src/state/MaterializedSettings.ts | 109 + packages/stack/src/state/Ownership.ts | 673 ++++ packages/stack/src/state/Paths.ts | 60 + packages/stack/src/state/PortCoordinator.ts | 493 +++ packages/stack/src/state/SecretStore.ts | 538 ++++ packages/stack/src/state/StackState.ts | 345 ++ packages/stack/src/state/StackStateStore.ts | 641 ++++ .../src/state/ownership.integration.test.ts | 571 ++++ .../stack/src/state/ports.integration.test.ts | 944 ++++++ .../src/state/secrets.integration.test.ts | 487 +++ .../src/state/state-store.integration.test.ts | 461 +++ .../stack/src/supervisor.integration.test.ts | 2190 ------------- packages/stack/src/supervisor.ts | 1069 ------- packages/stack/src/supervisor/HostListener.ts | 290 ++ packages/stack/src/supervisor/Ingress.ts | 402 +++ .../stack/src/supervisor/LaunchProtocol.ts | 31 + packages/stack/src/supervisor/Launcher.ts | 431 +++ packages/stack/src/supervisor/Lifecycle.ts | 343 ++ packages/stack/src/supervisor/LogStore.ts | 463 +++ .../stack/src/supervisor/SessionLauncher.ts | 199 ++ .../stack/src/supervisor/StatusProjection.ts | 129 + packages/stack/src/supervisor/Supervisor.ts | 917 ++++++ .../supervisor/handles.integration.test.ts | 674 ++++ .../host-listener.integration.test.ts | 120 + .../supervisor/ingress.integration.test.ts | 635 ++++ .../supervisor/lifecycle.integration.test.ts | 444 +++ .../observability.integration.test.ts | 346 ++ .../session-launcher.integration.test.ts | 211 ++ .../startup-ingress.integration.test.ts | 237 ++ .../supervisor/supervisor.integration.test.ts | 2354 ++++++++++++++ packages/stack/src/terminateChild.ts | 67 - .../stack/src/terminateChild.unit.test.ts | 113 - packages/stack/src/testing.ts | 56 +- packages/stack/src/version-plan.ts | 96 - packages/stack/src/version-plan.unit.test.ts | 93 - packages/stack/src/versions.ts | 113 - packages/stack/src/versions.unit.test.ts | 197 -- .../tests/createStack-docker.e2e.test.ts | 511 --- .../tests/createStack-native.e2e.test.ts | 518 --- packages/stack/tests/createStack.e2e.test.ts | 245 -- packages/stack/tests/global-setup.ts | 7 - .../tests/helpers/SupervisorSessionFixture.ts | 59 - .../helpers/compiled-supervisor-parent.ts | 76 - packages/stack/tests/helpers/e2e.ts | 84 - packages/stack/tests/helpers/file-watch.ts | 39 - packages/stack/tests/helpers/git-workspace.ts | 85 - .../stack/tests/helpers/managed-manager.ts | 204 -- packages/stack/tests/helpers/mocks.ts | 72 - .../stack/tests/helpers/port-lease-child.ts | 18 - packages/stack/tests/helpers/stack-ports.ts | 69 - .../stack/tests/helpers/supervisor-child.ts | 371 --- .../tests/helpers/supervisor-error-child.ts | 6 - .../helpers/supervisor-non-ready-child.ts | 33 - packages/stack/tests/helpers/warmup.ts | 49 - .../stack/tests/helpers/warmup.unit.test.ts | 115 - packages/stack/tests/warmup-e2e.ts | 3 - packages/stack/vitest.config.ts | 4 +- pnpm-lock.yaml | 181 +- pnpm-workspace.yaml | 16 +- turbo.json | 4 +- 408 files changed, 52377 insertions(+), 53873 deletions(-) delete mode 160000 .repos/process-compose create mode 100644 apps/cli/src/shared/stack-constants.ts delete mode 100644 apps/cli/tests/e2e-global-setup.ts rename packages/process-compose/tests/helpers/mocks.ts => apps/cli/tests/helpers/child-process-spawner.ts (100%) delete mode 100644 packages/process-compose/AGENTS.md delete mode 120000 packages/process-compose/CLAUDE.md delete mode 100644 packages/process-compose/README.md delete mode 100644 packages/process-compose/docs/architecture.md delete mode 100644 packages/process-compose/package.json delete mode 100644 packages/process-compose/src/DependencyGraph.ts delete mode 100644 packages/process-compose/src/DependencyGraph.unit.test.ts delete mode 100644 packages/process-compose/src/HealthProbe.ts delete mode 100644 packages/process-compose/src/HealthProbe.unit.test.ts delete mode 100644 packages/process-compose/src/LogBuffer.ts delete mode 100644 packages/process-compose/src/LogBuffer.unit.test.ts delete mode 100644 packages/process-compose/src/Orchestrator.integration.test.ts delete mode 100644 packages/process-compose/src/Orchestrator.ts delete mode 100644 packages/process-compose/src/Orchestrator.unit.test.ts delete mode 100644 packages/process-compose/src/RestartClosure.ts delete mode 100644 packages/process-compose/src/RestartClosure.unit.test.ts delete mode 100644 packages/process-compose/src/RestartDecision.ts delete mode 100644 packages/process-compose/src/RestartDecision.unit.test.ts delete mode 100644 packages/process-compose/src/ServiceDef.ts delete mode 100644 packages/process-compose/src/ServiceState.ts delete mode 100644 packages/process-compose/src/ServiceState.unit.test.ts delete mode 100644 packages/process-compose/src/ServiceTransition.ts delete mode 100644 packages/process-compose/src/ServiceTransition.unit.test.ts delete mode 100644 packages/process-compose/src/Supervisor.ts delete mode 100644 packages/process-compose/src/SupervisorRuntime.unit.test.ts delete mode 100644 packages/process-compose/src/errors.ts delete mode 100644 packages/process-compose/src/errors.unit.test.ts delete mode 100644 packages/process-compose/src/index.ts delete mode 100644 packages/process-compose/src/supervisor-protocol.ts delete mode 100644 packages/process-compose/src/supervisor-runtime.ts delete mode 100644 packages/process-compose/tsconfig.json delete mode 100644 packages/process-compose/vitest.config.ts delete mode 100644 packages/stack/docs/architecture.md delete mode 100644 packages/stack/docs/effect-platform-gaps.md delete mode 100644 packages/stack/docs/resource-leak-mitigations.md delete mode 100644 packages/stack/docs/service-versioning.md delete mode 100755 packages/stack/scripts/migrate-fast.sh delete mode 100644 packages/stack/scripts/sync-versions-from-dockerfile.ts delete mode 100644 packages/stack/src/ApiProxy.ts delete mode 100644 packages/stack/src/ApiProxy.unit.test.ts delete mode 100644 packages/stack/src/BinaryResolver.integration.test.ts delete mode 100644 packages/stack/src/BinaryResolver.ts delete mode 100644 packages/stack/src/BinaryResolver.unit.test.ts delete mode 100644 packages/stack/src/CleanupTargets.ts delete mode 100644 packages/stack/src/ContainerRuntime.integration.test.ts delete mode 100644 packages/stack/src/ContainerRuntime.ts delete mode 100644 packages/stack/src/ControlHttpReader.ts delete mode 100644 packages/stack/src/ControlStopClient.ts delete mode 100644 packages/stack/src/DaemonProtocol.ts delete mode 100644 packages/stack/src/HttpTransportClient.integration.test.ts delete mode 100644 packages/stack/src/HttpTransportClient.ts delete mode 100644 packages/stack/src/JwtGenerator.ts delete mode 100644 packages/stack/src/JwtGenerator.unit.test.ts delete mode 100644 packages/stack/src/LocalStack.ts delete mode 100644 packages/stack/src/Platform.ts delete mode 100644 packages/stack/src/Platform.unit.test.ts delete mode 100644 packages/stack/src/PortAllocator.integration.test.ts delete mode 100644 packages/stack/src/PortAllocator.ts delete mode 100644 packages/stack/src/PortCatalog.ts delete mode 100644 packages/stack/src/RemoteStack.rpc.bun.integration.test.ts delete mode 100644 packages/stack/src/RemoteStack.rpc.integration.test.ts delete mode 100644 packages/stack/src/RemoteStack.ts delete mode 100644 packages/stack/src/ServiceActivation.ts delete mode 100644 packages/stack/src/ServiceActivation.unit.test.ts delete mode 100644 packages/stack/src/ServiceCatalog.ts delete mode 100644 packages/stack/src/ServiceExclusions.ts delete mode 100644 packages/stack/src/ServiceName.ts delete mode 100644 packages/stack/src/ServicePorts.ts delete mode 100644 packages/stack/src/Stack.ts delete mode 100644 packages/stack/src/Stack.unit.test.ts delete mode 100644 packages/stack/src/StackBuilder.ts delete mode 100644 packages/stack/src/StackBuilder.unit.test.ts delete mode 100644 packages/stack/src/StackConfig.ts delete mode 100644 packages/stack/src/StackConfig.unit.test.ts delete mode 100644 packages/stack/src/StackConfigResolver.policy.unit.test.ts delete mode 100644 packages/stack/src/StackConfigResolver.ts delete mode 100644 packages/stack/src/StackIdentity.ts delete mode 100644 packages/stack/src/StackIdentity.unit.test.ts delete mode 100644 packages/stack/src/StackPreparation.ts delete mode 100644 packages/stack/src/StackRpc.integration.test.ts delete mode 100644 packages/stack/src/StackRpc.ts delete mode 100644 packages/stack/src/StackRpcHandlers.integration.test.ts delete mode 100644 packages/stack/src/StackRpcHandlers.ts delete mode 100644 packages/stack/src/StackServiceState.ts delete mode 100644 packages/stack/src/StackServiceState.unit.test.ts delete mode 100644 packages/stack/src/StackStateProjection.ts delete mode 100644 packages/stack/src/StackStateProjection.unit.test.ts delete mode 100644 packages/stack/src/SupervisorControlServer.integration.test.ts delete mode 100644 packages/stack/src/SupervisorControlServer.ts delete mode 100644 packages/stack/src/SupervisorProtocol.ts delete mode 100644 packages/stack/src/SupervisorSession.integration.test.ts delete mode 100644 packages/stack/src/SupervisorSession.ts delete mode 100644 packages/stack/src/SupervisorUpgradeRestart.integration.test.ts delete mode 100644 packages/stack/src/SupervisorUpgradeRestart.ts delete mode 100644 packages/stack/src/bun.ts delete mode 100644 packages/stack/src/cleanup.ts delete mode 100644 packages/stack/src/cleanup.unit.test.ts delete mode 100644 packages/stack/src/compiled-supervisor.integration.test.ts create mode 100644 packages/stack/src/control/ControlServer.ts create mode 100644 packages/stack/src/control/FrameCodec.ts create mode 100644 packages/stack/src/control/MaintenanceProtocol.ts create mode 100644 packages/stack/src/control/StackRpc.ts create mode 100644 packages/stack/src/control/control-transport.integration.test.ts delete mode 100644 packages/stack/src/createStack.integration.test.ts delete mode 100644 packages/stack/src/createStack.ts delete mode 100644 packages/stack/src/createStack.unit.test.ts delete mode 100644 packages/stack/src/daemon-bun.ts delete mode 100644 packages/stack/src/daemon-node.ts delete mode 100644 packages/stack/src/discovery.ts delete mode 100644 packages/stack/src/effect-bun.ts delete mode 100644 packages/stack/src/effect-delete.integration.test.ts delete mode 100644 packages/stack/src/effect-node.ts create mode 100644 packages/stack/src/entrypoints/supervisor-node.ts delete mode 100644 packages/stack/src/error-code.ts delete mode 100644 packages/stack/src/error-code.unit.test.ts delete mode 100644 packages/stack/src/errors.ts delete mode 100644 packages/stack/src/functions.ts delete mode 100644 packages/stack/src/functions.unit.test.ts create mode 100644 packages/stack/src/functions/FunctionsBootstrap.ts create mode 100644 packages/stack/src/functions/functions-bootstrap.integration.test.ts create mode 100644 packages/stack/src/functions/serve-main-bundler.ts create mode 100644 packages/stack/src/functions/serve-main-bundler.unit.test.ts create mode 100644 packages/stack/src/functions/serve-main-deps.ts create mode 100644 packages/stack/src/functions/serve-main-resolver.integration.test.ts create mode 100644 packages/stack/src/functions/serve-main-resolver.ts create mode 100644 packages/stack/src/functions/serve.main.ts create mode 100644 packages/stack/src/gateway/Gateway.ts create mode 100644 packages/stack/src/gateway/HttpGateway.ts create mode 100644 packages/stack/src/gateway/RouteCatalog.ts create mode 100644 packages/stack/src/gateway/TcpGateway.ts create mode 100644 packages/stack/src/gateway/gateway.integration.test.ts create mode 100644 packages/stack/src/gateway/route-catalog.integration.test.ts create mode 100644 packages/stack/src/gateway/startup-transports.integration.test.ts create mode 100644 packages/stack/src/identity/FolderIdentity.ts create mode 100644 packages/stack/src/identity/GitIdentity.ts create mode 100644 packages/stack/src/identity/Identity.ts create mode 100644 packages/stack/src/identity/identity.integration.test.ts create mode 100644 packages/stack/src/internal/supervisor-process.ts delete mode 100644 packages/stack/src/layers.ts delete mode 100644 packages/stack/src/managed-bun.ts delete mode 100644 packages/stack/src/managed-control.integration.test.ts delete mode 100644 packages/stack/src/managed-environment.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-lifecycle.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-ports.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-projects.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-recovery.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-worktrees.integration.test.ts delete mode 100644 packages/stack/src/managed-node.ts delete mode 100644 packages/stack/src/managed-paths.unit.test.ts delete mode 100644 packages/stack/src/managed-store.integration.test.ts delete mode 100644 packages/stack/src/managed.ts delete mode 100644 packages/stack/src/managed/atomic-claim.integration.test.ts delete mode 100644 packages/stack/src/managed/atomic-claim.ts delete mode 100644 packages/stack/src/managed/control.ts delete mode 100644 packages/stack/src/managed/document.ts delete mode 100644 packages/stack/src/managed/environment.ts delete mode 100644 packages/stack/src/managed/failure.ts delete mode 100644 packages/stack/src/managed/git-identity.ts delete mode 100644 packages/stack/src/managed/git.integration.test.ts delete mode 100644 packages/stack/src/managed/git.ts delete mode 100644 packages/stack/src/managed/identity.ts delete mode 100644 packages/stack/src/managed/ids.ts delete mode 100644 packages/stack/src/managed/lifecycle.ts delete mode 100644 packages/stack/src/managed/manager.ts delete mode 100644 packages/stack/src/managed/manager.unit.test.ts delete mode 100644 packages/stack/src/managed/model.ts delete mode 100644 packages/stack/src/managed/paths.ts delete mode 100644 packages/stack/src/managed/port-intent.ts delete mode 100644 packages/stack/src/managed/port-intent.unit.test.ts delete mode 100644 packages/stack/src/managed/port-plan.ts delete mode 100644 packages/stack/src/managed/port-plan.unit.test.ts delete mode 100644 packages/stack/src/managed/store.ts create mode 100644 packages/stack/src/model/CapabilityModule.ts create mode 100644 packages/stack/src/model/Compiler.ts create mode 100644 packages/stack/src/model/DatabaseBootstrap.ts create mode 100644 packages/stack/src/model/ExecutionPlan.ts create mode 100644 packages/stack/src/model/WorkloadCatalog.ts create mode 100644 packages/stack/src/model/capabilities/analytics.ts create mode 100644 packages/stack/src/model/capabilities/auth-third-party.ts create mode 100644 packages/stack/src/model/capabilities/auth.ts create mode 100644 packages/stack/src/model/capabilities/database.ts create mode 100644 packages/stack/src/model/capabilities/functions.ts create mode 100644 packages/stack/src/model/capabilities/index.ts create mode 100644 packages/stack/src/model/capabilities/mail.ts create mode 100644 packages/stack/src/model/capabilities/pooler.ts create mode 100644 packages/stack/src/model/capabilities/realtime.ts create mode 100644 packages/stack/src/model/capabilities/rest.ts create mode 100644 packages/stack/src/model/capabilities/storage.ts create mode 100644 packages/stack/src/model/capabilities/studio.ts create mode 100644 packages/stack/src/model/catalog.integration.test.ts create mode 100644 packages/stack/src/model/compiler.integration.test.ts create mode 100644 packages/stack/src/model/database-bootstrap.integration.test.ts delete mode 100644 packages/stack/src/node-entrypoint.integration.test.ts delete mode 100644 packages/stack/src/node.ts delete mode 100644 packages/stack/src/paths.ts delete mode 100644 packages/stack/src/platform-bun.integration.test.ts delete mode 100644 packages/stack/src/platform-bun.ts delete mode 100644 packages/stack/src/platform-node.integration.test.ts delete mode 100644 packages/stack/src/platform-node.ts delete mode 100644 packages/stack/src/prefetch.ts delete mode 100644 packages/stack/src/prefetch.unit.test.ts create mode 100644 packages/stack/src/preparation/ArtifactStore.ts create mode 100644 packages/stack/src/preparation/Integrity.ts create mode 100644 packages/stack/src/preparation/RuntimeArtifacts.ts create mode 100644 packages/stack/src/preparation/SlimServicesSource.ts create mode 100644 packages/stack/src/preparation/artifacts.integration.test.ts create mode 100644 packages/stack/src/preparation/runtime-artifacts.integration.test.ts create mode 100644 packages/stack/src/preparation/slim-services.integration.test.ts create mode 100644 packages/stack/src/public/Capability.ts create mode 100644 packages/stack/src/public/Config.ts create mode 100644 packages/stack/src/public/Credentials.ts create mode 100644 packages/stack/src/public/EffectStack.ts create mode 100644 packages/stack/src/public/Errors.ts create mode 100644 packages/stack/src/public/Logs.ts create mode 100644 packages/stack/src/public/PromiseStack.ts create mode 100644 packages/stack/src/public/Runtime.ts create mode 100644 packages/stack/src/public/StackId.ts create mode 100644 packages/stack/src/public/Status.ts create mode 100644 packages/stack/src/public/Testing.ts create mode 100644 packages/stack/src/public/effect-stack.integration.test.ts create mode 100644 packages/stack/src/public/index.ts create mode 100644 packages/stack/src/public/promise.integration.test.ts create mode 100644 packages/stack/src/public/public-model.integration.test.ts create mode 100644 packages/stack/src/public/testing.integration.test.ts create mode 100644 packages/stack/src/public/whole-stack.e2e.test.ts create mode 100644 packages/stack/src/runtime/ContainerEngine.ts create mode 100644 packages/stack/src/runtime/ContainerEngineResolver.ts create mode 100644 packages/stack/src/runtime/ContainerRuntime.ts create mode 100644 packages/stack/src/runtime/DatabaseBootstrapCatalog.ts create mode 100644 packages/stack/src/runtime/DockerEngine.ts create mode 100644 packages/stack/src/runtime/NativeProcess.ts create mode 100644 packages/stack/src/runtime/NativeRuntime.ts create mode 100644 packages/stack/src/runtime/PodmanEngine.ts create mode 100644 packages/stack/src/runtime/PostgresDatabaseSession.ts create mode 100644 packages/stack/src/runtime/ProductionRuntime.ts create mode 100644 packages/stack/src/runtime/ReadinessProbe.ts create mode 100644 packages/stack/src/runtime/RuntimeDriver.ts create mode 100644 packages/stack/src/runtime/RuntimeEnvFile.ts create mode 100644 packages/stack/src/runtime/RuntimeInputOwner.ts create mode 100644 packages/stack/src/runtime/WorkloadRuntimeSpec.ts create mode 100644 packages/stack/src/runtime/container-runtime.integration.test.ts create mode 100644 packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts create mode 100644 packages/stack/src/runtime/native-launcher.ts create mode 100644 packages/stack/src/runtime/native-runtime.integration.test.ts create mode 100644 packages/stack/src/runtime/postgres-database-session.integration.test.ts create mode 100644 packages/stack/src/runtime/production-runtime.integration.test.ts create mode 100644 packages/stack/src/runtime/readiness-probe.integration.test.ts create mode 100644 packages/stack/src/runtime/runtime-env-file.integration.test.ts create mode 100644 packages/stack/src/runtime/runtime-input-owner.integration.test.ts create mode 100644 packages/stack/src/runtime/workload-runtime.integration.test.ts delete mode 100644 packages/stack/src/services/analytics.ts delete mode 100644 packages/stack/src/services/auth.ts delete mode 100644 packages/stack/src/services/docker-cleanup.ts delete mode 100644 packages/stack/src/services/edge-runtime-main.ts delete mode 100644 packages/stack/src/services/edge-runtime.ts delete mode 100644 packages/stack/src/services/health-budgets.ts delete mode 100644 packages/stack/src/services/health-budgets.unit.test.ts delete mode 100644 packages/stack/src/services/imgproxy.ts delete mode 100644 packages/stack/src/services/mailpit.ts delete mode 100644 packages/stack/src/services/nofile-limit.ts delete mode 100644 packages/stack/src/services/nofile-limit.unit.test.ts delete mode 100644 packages/stack/src/services/pgmeta.ts delete mode 100644 packages/stack/src/services/pooler.ts delete mode 100644 packages/stack/src/services/postgres-init.ts delete mode 100644 packages/stack/src/services/postgres.ts delete mode 100644 packages/stack/src/services/postgrest.ts delete mode 100644 packages/stack/src/services/realtime.ts delete mode 100644 packages/stack/src/services/service-utils.ts delete mode 100644 packages/stack/src/services/services.unit.test.ts delete mode 100644 packages/stack/src/services/storage.ts delete mode 100644 packages/stack/src/services/studio.ts delete mode 100644 packages/stack/src/services/vector.ts delete mode 100644 packages/stack/src/services/vector.unit.test.ts delete mode 100644 packages/stack/src/stackHandle.ts create mode 100644 packages/stack/src/state/MaterializedSettings.ts create mode 100644 packages/stack/src/state/Ownership.ts create mode 100644 packages/stack/src/state/Paths.ts create mode 100644 packages/stack/src/state/PortCoordinator.ts create mode 100644 packages/stack/src/state/SecretStore.ts create mode 100644 packages/stack/src/state/StackState.ts create mode 100644 packages/stack/src/state/StackStateStore.ts create mode 100644 packages/stack/src/state/ownership.integration.test.ts create mode 100644 packages/stack/src/state/ports.integration.test.ts create mode 100644 packages/stack/src/state/secrets.integration.test.ts create mode 100644 packages/stack/src/state/state-store.integration.test.ts delete mode 100644 packages/stack/src/supervisor.integration.test.ts delete mode 100644 packages/stack/src/supervisor.ts create mode 100644 packages/stack/src/supervisor/HostListener.ts create mode 100644 packages/stack/src/supervisor/Ingress.ts create mode 100644 packages/stack/src/supervisor/LaunchProtocol.ts create mode 100644 packages/stack/src/supervisor/Launcher.ts create mode 100644 packages/stack/src/supervisor/Lifecycle.ts create mode 100644 packages/stack/src/supervisor/LogStore.ts create mode 100644 packages/stack/src/supervisor/SessionLauncher.ts create mode 100644 packages/stack/src/supervisor/StatusProjection.ts create mode 100644 packages/stack/src/supervisor/Supervisor.ts create mode 100644 packages/stack/src/supervisor/handles.integration.test.ts create mode 100644 packages/stack/src/supervisor/host-listener.integration.test.ts create mode 100644 packages/stack/src/supervisor/ingress.integration.test.ts create mode 100644 packages/stack/src/supervisor/lifecycle.integration.test.ts create mode 100644 packages/stack/src/supervisor/observability.integration.test.ts create mode 100644 packages/stack/src/supervisor/session-launcher.integration.test.ts create mode 100644 packages/stack/src/supervisor/startup-ingress.integration.test.ts create mode 100644 packages/stack/src/supervisor/supervisor.integration.test.ts delete mode 100644 packages/stack/src/terminateChild.ts delete mode 100644 packages/stack/src/terminateChild.unit.test.ts delete mode 100644 packages/stack/src/version-plan.ts delete mode 100644 packages/stack/src/version-plan.unit.test.ts delete mode 100644 packages/stack/src/versions.ts delete mode 100644 packages/stack/src/versions.unit.test.ts delete mode 100644 packages/stack/tests/createStack-docker.e2e.test.ts delete mode 100644 packages/stack/tests/createStack-native.e2e.test.ts delete mode 100644 packages/stack/tests/createStack.e2e.test.ts delete mode 100644 packages/stack/tests/global-setup.ts delete mode 100644 packages/stack/tests/helpers/SupervisorSessionFixture.ts delete mode 100644 packages/stack/tests/helpers/compiled-supervisor-parent.ts delete mode 100644 packages/stack/tests/helpers/e2e.ts delete mode 100644 packages/stack/tests/helpers/file-watch.ts delete mode 100644 packages/stack/tests/helpers/git-workspace.ts delete mode 100644 packages/stack/tests/helpers/managed-manager.ts delete mode 100644 packages/stack/tests/helpers/mocks.ts delete mode 100644 packages/stack/tests/helpers/port-lease-child.ts delete mode 100644 packages/stack/tests/helpers/stack-ports.ts delete mode 100644 packages/stack/tests/helpers/supervisor-child.ts delete mode 100644 packages/stack/tests/helpers/supervisor-error-child.ts delete mode 100644 packages/stack/tests/helpers/supervisor-non-ready-child.ts delete mode 100644 packages/stack/tests/helpers/warmup.ts delete mode 100644 packages/stack/tests/helpers/warmup.unit.test.ts delete mode 100644 packages/stack/tests/warmup-e2e.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de3dec7ff4..8cc068553b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,7 +148,7 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run end-to-end tests (shard ${{ matrix.shard }}/3) + name: Run CLI end-to-end tests (shard ${{ matrix.shard }}/3) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false @@ -186,16 +186,39 @@ jobs: run: pnpm exec turbo run supabase#build - name: Run end-to-end tests - run: pnpm exec turbo run test:e2e:run --only --concurrency=1 -- --shard=${{ matrix.shard }}/3 + run: pnpm exec turbo run test:e2e:run --only --concurrency=1 --filter=supabase --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/3 env: CLI_HARNESS_TARGET: ts-legacy SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go - # Summary job that gates branch protection. The matrix `test-e2e` job - # produces per-shard check names (`Run end-to-end tests (shard N/3)`), so - # this job preserves the original `Run end-to-end tests` check name that - # branch protection rules already require. It succeeds iff every shard - # succeeded (or skipped — `success()` is true for skipped jobs). + test-stack-e2e: + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event_name == 'merge_group' || + inputs.force || + github.event.pull_request.draft == false) + name: Run stack end-to-end tests (${{ matrix.runtime }}) + runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + runtime: [native, container] + steps: + - name: Checkout + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Run stack end-to-end tests + run: pnpm --filter @supabase/stack test:e2e:run + env: + SUPABASE_STACK_E2E_RUNTIME: ${{ matrix.runtime }} + + # Summary job that gates branch protection. It preserves the original check + # name and succeeds iff every CLI shard and stack runtime succeeded. test-e2e-summary: if: | always() && @@ -204,13 +227,13 @@ jobs: inputs.force || github.event.pull_request.draft == false) name: Run end-to-end tests - needs: test-e2e + needs: [test-e2e, test-stack-e2e] runs-on: ubuntu-latest steps: - name: Verify all shards succeeded run: | - if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ]; then - echo "::error ::One or more e2e shards failed: ${{ needs.test-e2e.result }}" + if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ] || [ "${{ needs.test-stack-e2e.result }}" = "failure" ] || [ "${{ needs.test-stack-e2e.result }}" = "cancelled" ]; then + echo "::error ::One or more e2e jobs failed: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" exit 1 fi - echo "All e2e shards reported: ${{ needs.test-e2e.result }}" + echo "All e2e jobs reported: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" diff --git a/.gitignore b/.gitignore index 5a892f3aba..a1d49ff0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ coverage/ !.env.example .claude/ .agents/.repos/effect-v3 +.repos/slim-services/ .worktrees/ .supabase/ +erl_crash.dump # Stray `supabase` project dir created by running the CLI at the repo root # (e.g. supabase/.temp/linked-project.json). This monorepo has no top-level # Supabase project — real fixtures live under apps/cli-e2e/fixtures/. @@ -23,6 +25,7 @@ packages/cli-*/bin/ # Turbo .turbo/ +apps/cli/.supabase/ # Transient render dir created by packages/api/scripts/generated-output-sync.unit.test.ts packages/api/.generated-output-sync-*/ diff --git a/.gitmodules b/.gitmodules index ec13d73342..084b300883 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,9 +13,6 @@ [submodule ".repos/cheffect"] path = .repos/cheffect url = https://github.com/tim-smart/cheffect.git -[submodule ".repos/process-compose"] - path = .repos/process-compose - url = https://github.com/F1bonacc1/process-compose.git [submodule ".repos/t3code"] path = .repos/t3code url = https://github.com/pingdotgg/t3code.git diff --git a/.oxlintrc.json b/.oxlintrc.json index 4d587a2f9a..7639dd613e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -8,8 +8,6 @@ ".repos", "apps/cli-go", "apps/cli-e2e/fixtures", - "packages/stack", - "packages/process-compose", "**/testdata", "**/dist", "**/coverage", diff --git a/.repos/process-compose b/.repos/process-compose deleted file mode 160000 index a4038d6698..0000000000 --- a/.repos/process-compose +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4038d669818c35fc68fc7fc240b39e371ce0e7a diff --git a/AGENTS.md b/AGENTS.md index a41624df94..ffa5c1c6da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,13 +12,12 @@ Bun monorepo with workspaces under `apps/` and `packages/`. - `apps/docs` — internal Next.js docs site - `packages/api` — typed Supabase Management API client - `packages/config` — config schema and generated types -- `packages/process-compose` — process orchestration library - `packages/stack` — programmatic local Supabase stack runtime - `packages/cli-*` — platform-specific published CLI binary wrappers ## Package Structure -Use `packages/process-compose` as the reference for internal TypeScript/Bun workspaces such as `apps/cli`, `packages/api`, `packages/config`, `packages/process-compose`, and `packages/stack`. +Use the existing internal TypeScript/Bun workspaces as references for package structure and scripts. These workspaces should generally follow this structure: @@ -29,7 +28,7 @@ These workspaces should generally follow this structure: - Standard scripts: `test`, `types:check` - Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript` -Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting is incrementally scoped to `packages/stack` and `packages/process-compose` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. +Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting is scoped to `packages/stack` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. Expected exceptions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b20df8868..fdd93672a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,6 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP |-- packages/ | |-- api/ # Typed Supabase Management API client | |-- config/ # Supabase config schema and generated types -| |-- process-compose/ # Effect-based process orchestration library | |-- stack/ # Programmatic local Supabase stack runtime | `-- cli-*/ # Platform-specific CLI binary packages |-- tools/ # Repository tooling (release scripts, etc.) @@ -117,7 +116,6 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP | `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | | `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | | `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | -| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | | `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | | `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | | `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | @@ -142,7 +140,7 @@ pnpm run fix:all # run all fixers across every project ### Standard package scripts -Standard TypeScript workspaces (`apps/cli-e2e`, `apps/cli`, `packages/api`, `packages/cli-test-helpers`, `packages/config`, `packages/process-compose`, `packages/stack`) declare their package scripts explicitly. Test suites vary by package: unit tests are standard, while integration and e2e tests exist only where applicable. +Standard TypeScript workspaces (`apps/cli-e2e`, `apps/cli`, `packages/api`, `packages/cli-test-helpers`, `packages/config`, `packages/stack`) declare their package scripts explicitly. Test suites vary by package: unit tests are standard, while integration and e2e tests exist only where applicable. | Script | What it does | | ------------------ | -------------------------------------- | diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b65c1a3cae..27c1a8e15f 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -383,7 +383,7 @@ export class LegacyThingMissingError extends Data.TaggedError("LegacyThingMissin - **An instance-dependent getter must stay valid when its fields are absent** — the drift guard evaluates it against a field-less probe. - **A plain `Error` subclass (no `_tag`) also declares its fingerprint identifier**: `static readonly [ErrorActionabilityFingerprintId] = ""`, matching the export name exactly. Tagged errors skip this — their fingerprint comes from the tag. The static identifier is what keeps `error:` fingerprints stable in minified release builds, where `constructor.name` is renamed. -**Errors defined outside `apps/cli/src`** (`@supabase/stack`, `@supabase/config`, `@supabase/process-compose`, `@supabase/api`, `effect`) cannot carry a declaration. Add a structural adapter keyed by `_tag` to `externalActionabilityByTag` in that same module, branching on the producer's typed fields. +**Errors defined outside `apps/cli/src`** (`@supabase/stack`, `@supabase/config`, `@supabase/api`, `effect`) cannot carry a declaration. Add a structural adapter keyed by `_tag` to `externalActionabilityByTag` in that same module, branching on the producer's typed fields. `error-actionability-coverage.unit.test.ts` enforces this. It scans every `TaggedError("Tag")`, every `*Error("Tag")` factory, and every `class X extends Error` under `apps/cli/src`, and fails when a class is unexported, has no own declaration, or is untagged without its matching static fingerprint identifier. A failure there is the guard working: classify the new error rather than loosening the guard, because `unknown` in production telemetry must mean a genuinely unforeseen failure, not one nobody categorized. diff --git a/apps/cli/package.json b/apps/cli/package.json index 3c1909d587..adb4c11221 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -58,7 +58,6 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.49", "@supabase/pg-topo": "1.0.0-alpha.6", - "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.23.1", diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index 5480a2393a..5645011f91 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -69,7 +69,7 @@ export interface LegacyHealthCheckFailure { } /** Runtime-internal probe sentinel; exported only for the exhaustive actionability guard. */ -export class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ +class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ readonly failures: ReadonlyArray; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index ffc6aee7ed..32585d8dc7 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -70,7 +70,7 @@ import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts"; * actionability guard can inspect its declaration; runtime callers consume the * enclosing effect rather than importing this class. */ -export class LegacyResetLocalDbNotRunningError extends Data.TaggedError( +class LegacyResetLocalDbNotRunningError extends Data.TaggedError( "LegacyResetLocalDbNotRunningError", )<{ readonly message: string; diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index d1b944bc41..141fc6239e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -120,7 +120,7 @@ const legacyShadowCacheUnavailable = ( // --------------------------------------------------------------------------- /** One of the three PG15+ one-shot migrate jobs, as the cache key sees it. */ -export interface LegacyShadowCacheServiceInput { +interface LegacyShadowCacheServiceInput { readonly enabled: boolean; /** Registry-resolved image; hashed only when {@link enabled}. */ readonly image: string; diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index 913d83aaee..e2f4e6081b 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -155,7 +155,7 @@ export const LEGACY_SHADOW_CREATE_TEMPLATE_SQL = * (`apps/cli-go/internal/db/diff/diff.go:187,200`, `internal/migration/squash/squash.go:91`) * passes the same `10*time.Second` literal. */ -export const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; +const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; /** * Go's `NewBackoffPolicy(ctx, timeout)` (`apps/cli-go/internal/db/start/start.go:192-198`): a @@ -373,7 +373,7 @@ export interface LegacyShadowSourceResult { } /** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` and the shadow readiness probes. */ -export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { +interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; readonly hostname: string; @@ -777,7 +777,7 @@ export interface LegacyShadowBaselineState { } /** The baseline state every uncached caller passes: provision it, snapshot nothing. */ -export const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { +const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { baselinePresent: false, snapshotRequired: false, snapshotBaseline: Effect.void, diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 986a394224..95c4851437 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -109,7 +109,7 @@ type Spawner = ChildProcessSpawner["Service"]; * Exported only so the exhaustive actionability guard can inspect its declaration; * runtime callers observe it through {@link LegacyStartDatabaseError}. */ -export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( +class LegacyStartBackupVolumeExistsError extends Data.TaggedError( "LegacyStartBackupVolumeExistsError", )<{ readonly message: string; diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index f183673e6d..d3292c27a7 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -99,9 +99,9 @@ function wrapDbConfigOverride( }); } -export type LegacyStartLocalDatabaseStatus = "already-running" | "started"; +type LegacyStartLocalDatabaseStatus = "already-running" | "started"; -export interface LegacyStartLocalDatabaseResult { +interface LegacyStartLocalDatabaseResult { readonly status: LegacyStartLocalDatabaseStatus; } diff --git a/apps/cli/src/command-internal/legacy-config-validate.ts b/apps/cli/src/command-internal/legacy-config-validate.ts index aaca5ce204..02bbee59b3 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.ts @@ -918,7 +918,7 @@ export function legacyResolveEmailTemplateContentPath(args: { * loading, and the Kong template mount builder so every consumer sees the * SAME file. */ -export function legacyResolveNotificationContentPath(base: string, contentPath: string): string { +function legacyResolveNotificationContentPath(base: string, contentPath: string): string { if (isAbsolute(contentPath)) return contentPath; const resolved = join(base, contentPath); if (!legacyIsExistingFile(resolved)) { diff --git a/apps/cli/src/command-internal/legacy-db-config.toml-read.ts b/apps/cli/src/command-internal/legacy-db-config.toml-read.ts index e8bd13e24d..5f751e4af2 100644 --- a/apps/cli/src/command-internal/legacy-db-config.toml-read.ts +++ b/apps/cli/src/command-internal/legacy-db-config.toml-read.ts @@ -169,7 +169,7 @@ interface LegacyDbVaultSecretToml { * shape instead of re-declaring it inline, making field drift a compile error * rather than a silent cache-key gap. */ -export interface LegacyBaselineTomlConfig { +interface LegacyBaselineTomlConfig { /** `[auth] enabled`, default true. Gates `initSchema`'s auth service migration. */ readonly authEnabled: boolean; /** `[storage] enabled`, default true. */ @@ -301,7 +301,7 @@ function legacyResolveValidatedRemoteProjectId( * When a matched `[remotes.*]` block supplies any of these, the block value * must beat the matching env override. */ -export const LEGACY_ENV_OVERRIDABLE_KEYS = [ +const LEGACY_ENV_OVERRIDABLE_KEYS = [ // The matched `[remotes.]` block's own `project_id` field is what selected it in the // first place (`applyRemoteOverride` above matches on exactly this key) — same override-tier // reasoning as every other key in this array. NOT guaranteed present, though: a block can also diff --git a/apps/cli/src/command-internal/legacy-db-image.ts b/apps/cli/src/command-internal/legacy-db-image.ts index b2c4798274..9ef0e418ad 100644 --- a/apps/cli/src/command-internal/legacy-db-image.ts +++ b/apps/cli/src/command-internal/legacy-db-image.ts @@ -54,16 +54,6 @@ function compareSemver(a: string, b: string): number { return 0; } -export interface LegacyResolvedDbImage { - /** Pull/create reference — slim-translated when the flag is on and the pin is current. */ - readonly image: string; - /** - * Unprefixed docker.io / OrioleDB / 13–15 identity for version-compare. - * Never `ghcr.io/...` — {@link legacyPostgresImageVersionTag} splits on the first `:`. - */ - readonly configImage: string; -} - /** * Resolve the Postgres image for `majorVersion`, honoring the pinned version * written by `supabase start` to `supabase/.temp/postgres-version` (Go reads diff --git a/apps/cli/src/command-internal/legacy-docker-lifecycle.ts b/apps/cli/src/command-internal/legacy-docker-lifecycle.ts index d8e8470163..1bf6d1bfa4 100644 --- a/apps/cli/src/command-internal/legacy-docker-lifecycle.ts +++ b/apps/cli/src/command-internal/legacy-docker-lifecycle.ts @@ -1,4 +1,4 @@ -import { isDockerDaemonDownMessage } from "@supabase/stack/effect"; +import { isDockerDaemonDownMessage } from "../shared/stack-constants.ts"; import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; diff --git a/apps/cli/src/command-internal/legacy-docker-remove-all.ts b/apps/cli/src/command-internal/legacy-docker-remove-all.ts index d16158aa34..8b49b879d0 100644 --- a/apps/cli/src/command-internal/legacy-docker-remove-all.ts +++ b/apps/cli/src/command-internal/legacy-docker-remove-all.ts @@ -27,9 +27,7 @@ type Spawner = ChildProcessSpawner["Service"]; * their string `_tag`, never by importing the classes themselves. The classes * are exported so the exhaustive telemetry guard can verify their declarations. */ -export class LegacyDockerRemoveAllListError extends Data.TaggedError( - "LegacyDockerRemoveAllListError", -)<{ +class LegacyDockerRemoveAllListError extends Data.TaggedError("LegacyDockerRemoveAllListError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -37,9 +35,7 @@ export class LegacyDockerRemoveAllListError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllStopError extends Data.TaggedError( - "LegacyDockerRemoveAllStopError", -)<{ +class LegacyDockerRemoveAllStopError extends Data.TaggedError("LegacyDockerRemoveAllStopError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -47,7 +43,7 @@ export class LegacyDockerRemoveAllStopError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( +class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( "LegacyDockerRemoveAllContainerPruneError", )<{ readonly message: string; @@ -57,7 +53,7 @@ export class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( +class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( "LegacyDockerRemoveAllVolumePruneError", )<{ readonly message: string; @@ -67,7 +63,7 @@ export class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( +class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( "LegacyDockerRemoveAllNetworkPruneError", )<{ readonly message: string; diff --git a/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts b/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts index 9f0dc26341..6fcc794d84 100644 --- a/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts +++ b/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts @@ -48,7 +48,7 @@ export interface LegacyEdgeRuntimeRunOpts { readonly workdir?: string; } -export interface LegacyEdgeRuntimeRunResult { +interface LegacyEdgeRuntimeRunResult { readonly stdout: string; readonly stderr: string; } diff --git a/apps/cli/src/command-internal/legacy-local-config-values.ts b/apps/cli/src/command-internal/legacy-local-config-values.ts index 2dab9d896b..ae741e9245 100644 --- a/apps/cli/src/command-internal/legacy-local-config-values.ts +++ b/apps/cli/src/command-internal/legacy-local-config-values.ts @@ -3,7 +3,11 @@ import { basename } from "node:path"; import type { CliConfig } from "@supabase/config"; import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; -import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { + defaultJwtSecret, + defaultPublishableKey, + defaultSecretKey, +} from "../shared/stack-constants.ts"; import { Schema } from "effect"; import { diff --git a/apps/cli/src/command-internal/legacy-password-requirements.ts b/apps/cli/src/command-internal/legacy-password-requirements.ts index 5488817f97..06baf8a5a1 100644 --- a/apps/cli/src/command-internal/legacy-password-requirements.ts +++ b/apps/cli/src/command-internal/legacy-password-requirements.ts @@ -7,7 +7,7 @@ * GoTrue service environment (`GOTRUE_PASSWORD_REQUIRED_CHARACTERS`), so the * two can never drift apart. */ -export const LEGACY_PASSWORD_REQUIREMENTS_TO_CHAR: Readonly> = { +const LEGACY_PASSWORD_REQUIREMENTS_TO_CHAR: Readonly> = { letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", lower_upper_letters_digits: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", lower_upper_letters_digits_symbols: diff --git a/apps/cli/src/command-internal/legacy-size-units.ts b/apps/cli/src/command-internal/legacy-size-units.ts index 68cf70e2ab..e17cb5d781 100644 --- a/apps/cli/src/command-internal/legacy-size-units.ts +++ b/apps/cli/src/command-internal/legacy-size-units.ts @@ -1,9 +1,7 @@ /** * Ports of `github.com/docker/go-units` used by Go's `sizeInBytes` * (`pkg/config/config.go`). `file_size_limit` config values are parsed with - * `RAMInBytes` and re-serialised in the diff with `BytesSize` (`sizeInBytes` - * implements `MarshalText`, so BurntSushi emits a quoted human-readable size, - * e.g. `"5MiB"`). + * `RAMInBytes` before being sent to service APIs. * * Shared across the legacy shell: `config push` (storage/auth/api/db diffing) * and `seed buckets` (which converts each `[storage.buckets.*].file_size_limit` @@ -20,8 +18,6 @@ const BINARY_MAP: Readonly> = { p: 1024 ** 5, }; -const BINARY_ABBRS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const; - const DIGIT_OR_DOT_OR_SPACE = "0123456789. "; /** @@ -100,37 +96,3 @@ export function ramInBytes(sizeStr: string): number { } return Math.trunc(size * mul); } - -/** - * Port of Go's `fmt`-style `%.4g`: at most 4 significant digits, trailing zeros - * removed, no exponent for the magnitudes `BytesSize` produces (scaled to - * `[0, 1024)`). - */ -function formatG4(n: number): string { - if (n === 0) return "0"; - let s = n.toPrecision(4); - if (s.includes("e") || s.includes("E")) { - return s; - } - if (s.includes(".")) { - s = s.replace(/0+$/, "").replace(/\.$/, ""); - } - return s; -} - -/** Port of Go `cast.IntToUint`: clamp negative values to 0 (Go takes an `int`, so no truncation). */ -export function intToUint(value: number): number { - return value < 0 ? 0 : value; -} - -/** Port of `units.BytesSize` — `CustomSize("%.4g%s", size, 1024, binaryAbbrs)`. */ -export function bytesSize(size: number): string { - let value = size; - let i = 0; - const limit = BINARY_ABBRS.length - 1; - while (value >= 1024 && i < limit) { - value = value / 1024; - i++; - } - return formatG4(value) + BINARY_ABBRS[i]; -} diff --git a/apps/cli/src/command-internal/legacy-storage-credentials.ts b/apps/cli/src/command-internal/legacy-storage-credentials.ts index eea990b3f6..284bbd374f 100644 --- a/apps/cli/src/command-internal/legacy-storage-credentials.ts +++ b/apps/cli/src/command-internal/legacy-storage-credentials.ts @@ -1,4 +1,4 @@ -import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; +import { defaultJwtSecret, generateJwt } from "../shared/stack-constants.ts"; import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; diff --git a/apps/cli/src/command-internal/legacy-workdir-project.ts b/apps/cli/src/command-internal/legacy-workdir-project.ts index 4fa52f5627..c753fa5e29 100644 --- a/apps/cli/src/command-internal/legacy-workdir-project.ts +++ b/apps/cli/src/command-internal/legacy-workdir-project.ts @@ -83,7 +83,7 @@ export const legacyMissingProjectConfigMessageEffect = Effect.fnUntraced(functio * their own command-specific error type, matching the established pattern * for `LegacyWorkdirValidationError`. */ -export class LegacyWorkdirProjectMissingError extends Data.TaggedError( +class LegacyWorkdirProjectMissingError extends Data.TaggedError( "LegacyWorkdirProjectMissingError", )<{ readonly message: string; diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index e0b58dc86a..6afdd0e0aa 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -39,9 +39,7 @@ const config = { export type LegacyConfigPullFlags = CliCommand.Command.Config.Infer; -// Exported so integration tests can drive the exact wiring -// `Command.withHandler` uses below (same precedent as `legacyConfigDiffHandler`). -export const legacyConfigPullHandler = (flags: LegacyConfigPullFlags) => +const legacyConfigPullHandler = (flags: LegacyConfigPullFlags) => legacyConfigPull(flags).pipe( // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so its // value is only safe to log verbatim when it is actually ref-shaped — a diff --git a/apps/cli/src/commands/config/pull/pull.format.ts b/apps/cli/src/commands/config/pull/pull.format.ts index d6f987c6cb..2d3b102f6c 100644 --- a/apps/cli/src/commands/config/pull/pull.format.ts +++ b/apps/cli/src/commands/config/pull/pull.format.ts @@ -44,7 +44,7 @@ export const LEGACY_CONFIG_PULL_PAYLOAD_VERSION = 1; * change `legacyPlanConfigPull` planned to write still ends up unwritten when * the run is a dry run or the user declined. */ -export type LegacyConfigPullChangeSkipReason = LegacyConfigPullSkipReason | "declined" | "dry_run"; +type LegacyConfigPullChangeSkipReason = LegacyConfigPullSkipReason | "declined" | "dry_run"; /** * The run's actual outcome, known only after the confirmation prompt (or diff --git a/apps/cli/src/commands/config/pull/pull.plan.ts b/apps/cli/src/commands/config/pull/pull.plan.ts index bb5702c61a..dd61e462c6 100644 --- a/apps/cli/src/commands/config/pull/pull.plan.ts +++ b/apps/cli/src/commands/config/pull/pull.plan.ts @@ -55,12 +55,12 @@ export type LegacyConfigPullSkipReason = | "unwritable" | "would_invalidate"; -export interface LegacyConfigPullSkip { +interface LegacyConfigPullSkip { readonly change: ConfigChange; readonly reason: LegacyConfigPullSkipReason; } -export interface LegacyConfigPullPlannedWrite { +interface LegacyConfigPullPlannedWrite { readonly change: ConfigChange; /** `change.path`, prefixed with `["remotes", label]` when the destination * is a `[remotes.*]` block — the exact path `applyConfigEdits` edits. */ @@ -68,7 +68,7 @@ export interface LegacyConfigPullPlannedWrite { readonly value: ConfigEditValue; } -export type LegacyConfigPullWarningKind = +type LegacyConfigPullWarningKind = | "dual_scope" | "duplicates_root" | "array_drift" diff --git a/apps/cli/src/commands/config/pull/pull.scope.ts b/apps/cli/src/commands/config/pull/pull.scope.ts index a4b2712e5c..3efb492830 100644 --- a/apps/cli/src/commands/config/pull/pull.scope.ts +++ b/apps/cli/src/commands/config/pull/pull.scope.ts @@ -39,11 +39,11 @@ import { legacySanitizeInlineName } from "../../../command-internal/legacy-http- * 5. Otherwise: the config root. */ -export interface LegacyConfigPullDestinationRoot { +interface LegacyConfigPullDestinationRoot { readonly kind: "root"; } -export interface LegacyConfigPullDestinationRemote { +interface LegacyConfigPullDestinationRemote { readonly kind: "remote"; /** * The `[remotes.