From 6cc6ae2895bbaafa73d08273c1724438b8f05d7c Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 10:37:20 +0000 Subject: [PATCH 01/11] ci: centralize Claude and Codex model ids (#7111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `.github/ai-models.json`, which holds model ids keyed by family and tier (`claude.opus`, `claude.sonnet`, `claude.haiku`, `codex.sol`). Bumping a tier there upgrades every consumer of that tier. - `ai-review.yml` drops the hardcoded `CLAUDE_MODEL` / `CODEX_MODEL` workflow env. The `resolve` job reads both from the trusted default-branch copy and passes them to the other jobs as outputs, so a PR can't change the model that reviews it and every job in a run uses the same models. - `sync-branches.yml` and `sync-repair.yml` read `claude.opus` from the file instead of pinning it. - `propose-release-notes.ts` imports the file and defaults `--model` to `claude.haiku`. The file is listed as release infrastructure in `release-process.md`, so a model change there may need porting to active `v*.x` lines. A model bump can still need the Claude Code or Codex CLI version pinned in these workflows bumped too; the file's `$comment` says so. The review reads the file from `develop`, so the AI review on this PR fails at `resolve` until it merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/ai-models.json | 11 ++++++++++ .github/scripts/ai-review/post-review.ts | 4 ++-- .github/workflows/ai-review.yml | 26 ++++++++++++++++------- .github/workflows/sync-branches.yml | 8 +++++-- .github/workflows/sync-repair.yml | 4 +++- apps/cli/docs/release-process.md | 3 ++- apps/cli/scripts/propose-release-notes.ts | 3 ++- 7 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 .github/ai-models.json diff --git a/.github/ai-models.json b/.github/ai-models.json new file mode 100644 index 0000000000..73431a1116 --- /dev/null +++ b/.github/ai-models.json @@ -0,0 +1,11 @@ +{ + "$comment": "Model IDs by family and tier. A new model can also need the Claude Code or Codex CLI version pinned in the workflows that read this file bumped.", + "claude": { + "opus": "claude-opus-5-5", + "sonnet": "claude-sonnet-5-5", + "haiku": "claude-haiku-5-5" + }, + "codex": { + "sol": "gpt-6.1-sol" + } +} diff --git a/.github/scripts/ai-review/post-review.ts b/.github/scripts/ai-review/post-review.ts index 47a73a943e..6a7d1dbd1e 100644 --- a/.github/scripts/ai-review/post-review.ts +++ b/.github/scripts/ai-review/post-review.ts @@ -603,8 +603,8 @@ export function renderInlineComment(finding: MergedFinding): string { export interface ReviewFooterInfo { trigger: Trigger; runUrl: string; - /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from the workflow's - * model env vars so there's one source of truth for the model names. */ + /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from + * `.github/ai-models.json` through the workflow's model env vars. */ modelsFooter: string; } diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 167cdd408b..5140c4142b 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -26,12 +26,6 @@ on: permissions: {} -# One source of truth for the model names; resolve, claude-review, codex-review, and -# post-review's footer all read these instead of hardcoding them separately. -env: - CLAUDE_MODEL: claude-opus-5-5 - CODEX_MODEL: gpt-6.1-sol - # Non-command issue_comment events fire for every comment on every PR, so grouping by PR number # alone would let any comment cancel an in-flight review; give those runs their own per-run # group instead. The command check is exact equality (mirroring resolve.ts), so a near-miss like @@ -73,6 +67,9 @@ jobs: pr_number: ${{ steps.resolve.outputs.pr_number }} head_ref: ${{ steps.resolve.outputs.head_ref }} trigger: ${{ steps.resolve.outputs.trigger }} + # Read once here so every job in the run uses the same models, even if the file changes mid-run. + claude_model: ${{ steps.models.outputs.claude_model }} + codex_model: ${{ steps.models.outputs.codex_model }} steps: # Pinned to the default ref explicitly — this job must keep running only trusted repo code, # even though the pull_request trigger above hands it PR-authored event payloads. @@ -95,6 +92,9 @@ jobs: COMMENT_AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} COMMENT_BODY: ${{ github.event.comment.body }} run: bun .github/scripts/ai-review/resolve.ts + - name: Load AI models + id: models + run: jq -r '"claude_model=\(.claude.opus)", "codex_model=\(.codex.sol)"' .github/ai-models.json >> "$GITHUB_OUTPUT" claude-review: name: Claude review @@ -110,6 +110,8 @@ jobs: permissions: contents: read pull-requests: read + env: + CLAUDE_MODEL: ${{ needs.resolve.outputs.claude_model }} steps: - name: Checkout PR head (untrusted; review subject matter only) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -266,6 +268,8 @@ jobs: pull-requests: read timeout-minutes: 45 runs-on: ubuntu-latest + env: + CODEX_MODEL: ${{ needs.resolve.outputs.codex_model }} steps: - name: Checkout default branch (trusted; the only checkout this job needs) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -371,6 +375,8 @@ jobs: pull-requests: read timeout-minutes: 45 runs-on: ubuntu-latest + env: + CODEX_MODEL: ${{ needs.resolve.outputs.codex_model }} steps: - name: Checkout PR head (untrusted; read-only, for verify-by-reading) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -525,6 +531,9 @@ jobs: timeout-minutes: 10 permissions: pull-requests: write + env: + CLAUDE_MODEL: ${{ needs.resolve.outputs.claude_model }} + CODEX_MODEL: ${{ needs.resolve.outputs.codex_model }} steps: # Security-critical: this is the only job with write permission, so it must only ever # execute trusted base-branch code — never the PR head. Checking out `develop` explicitly @@ -537,6 +546,7 @@ jobs: with: ref: develop persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ".bun-version" @@ -562,6 +572,6 @@ jobs: PR_DIFF_PATH: /tmp/ai-review/pr.diff TRIGGER: ${{ needs.resolve.outputs.trigger }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level `env:` block above, - # so the footer never drifts from what actually ran. + # CLAUDE_MODEL / CODEX_MODEL are the job-level values from `resolve`, the same ones the + # review jobs ran with, so the footer never drifts from what actually ran. run: bun .github/scripts/ai-review/post-review.ts post diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml index db3f258131..022f59abc8 100644 --- a/.github/workflows/sync-branches.yml +++ b/.github/workflows/sync-branches.yml @@ -104,7 +104,6 @@ jobs: outputs: resolved: ${{ steps.resolve.outputs.resolved }} env: - CLAUDE_MODEL: claude-opus-5-5 OUTPUT_DIR: ${{ github.workspace }}/sync-agent RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: @@ -113,6 +112,9 @@ jobs: path: trusted persist-credentials: false + - name: Load Claude model + run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: work @@ -229,7 +231,6 @@ jobs: permissions: contents: read env: - CLAUDE_MODEL: claude-opus-5-5 OUTPUT_DIR: ${{ github.workspace }}/sync-agent steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -237,6 +238,9 @@ jobs: path: trusted persist-credentials: false + - name: Load Claude model + run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: work diff --git a/.github/workflows/sync-repair.yml b/.github/workflows/sync-repair.yml index cab596657d..530507652d 100644 --- a/.github/workflows/sync-repair.yml +++ b/.github/workflows/sync-repair.yml @@ -41,7 +41,6 @@ jobs: checks: read actions: read env: - CLAUDE_MODEL: claude-opus-5-5 OUTPUT_DIR: ${{ github.workspace }}/sync-agent RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: @@ -50,6 +49,9 @@ jobs: path: trusted persist-credentials: false + - name: Load Claude model + run: echo "CLAUDE_MODEL=$(jq -r .claude.opus trusted/.github/ai-models.json)" >> "$GITHUB_ENV" + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: trusted/.bun-version diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index f36ffb85ec..753cef6099 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -354,7 +354,7 @@ git push origin sync/develop-into-next #### Claude-resolved `develop-into-next` -A `develop-into-next` conflict PR is resolved by Claude (`claude-opus-5-5`) in the same run. The `Resolve conflicts with Claude` job merges `next` into the sync branch and runs Claude Code with file read, search, and edit tools only: no shell, no web tools, no `.git` edits, and no GitHub write token. A `pnpm-lock.yaml` conflict keeps `next`'s lockfile, which the next job regenerates. `Run quick checks on the resolution` formats the merged tree and runs `check:all` and unit tests in a job with no secrets, and `Fix the quick checks with Claude` fixes what fails once, without re-running them. This catches code on one side that still uses something the other renamed or removed; integration and end-to-end failures are left to the PR's CI and the repair loop below. The jobs holding the Anthropic key never execute the merged tree, and the tree passes between jobs as a git bundle. The `Publish the resolution` job pushes the latest finished stage from a fresh checkout with the release App token, comments the resolution and any check that failed, and starts the AI review. Choices between `develop` and `next` behavior are listed as decisions for `@supabase/cli`. If Claude cannot resolve the merge, it comments why and the PR waits for a person as above. +A `develop-into-next` conflict PR is resolved by Claude (the `claude.opus` model in `.github/ai-models.json`) in the same run. The `Resolve conflicts with Claude` job merges `next` into the sync branch and runs Claude Code with file read, search, and edit tools only: no shell, no web tools, no `.git` edits, and no GitHub write token. A `pnpm-lock.yaml` conflict keeps `next`'s lockfile, which the next job regenerates. `Run quick checks on the resolution` formats the merged tree and runs `check:all` and unit tests in a job with no secrets, and `Fix the quick checks with Claude` fixes what fails once, without re-running them. This catches code on one side that still uses something the other renamed or removed; integration and end-to-end failures are left to the PR's CI and the repair loop below. The jobs holding the Anthropic key never execute the merged tree, and the tree passes between jobs as a git bundle. The `Publish the resolution` job pushes the latest finished stage from a fresh checkout with the release App token, comments the resolution and any check that failed, and starts the AI review. Choices between `develop` and `next` behavior are listed as decisions for `@supabase/cli`. If Claude cannot resolve the merge, it comments why and the PR waits for a person as above. [`sync-repair.yml`](../../../.github/workflows/sync-repair.yml) (`Sync repair`) runs when a CI workflow or the AI review finishes, and hourly. Once every check on the PR head has finished and no `AI Review` run for the PR is still going, it gives Claude the failed jobs' log tails and the AI review's unanswered threads. Claude fixes what the merge broke and replies on each finding, declining findings the merge did not introduce. The fix is pushed as a `chore(repo)` commit, fixed threads are resolved, and a round comment is posted. A head gets another round only for new review findings. When a round leaves its head failing, or after two rounds, the bot asks `@supabase/cli` to take over and stops repairing that PR. The repair loop reads only CI output and the AI review's own threads, never other people's comments, and only acts on PRs Claude resolved. @@ -487,6 +487,7 @@ Keep two to three stable majors supported at the same time, roughly six to twelv - Live e2e workflows: `live-e2e.yml`, `live-e2e-suite.yml`, `live-e2e-gate.yml`, `live-e2e-notify.yml`. - PR gating on `v*.x`: `test.yml`, `lint-pull-request.yml`, `run-ci.yml`, `branch-policy.yml`. - Composite action `.github/actions/setup`. +- Model ids in `.github/ai-models.json`, read by `propose-release-notes.ts`. - Scripts in `apps/cli/scripts/`: `build.ts`, `build-binary.ts`, `compile-options.ts`, `macos-signing.ts`, `publish.ts`, `release-channels.ts`, `sync-versions.ts`, `update-homebrew.ts`, `update-scoop.ts`, `upload-release-assets.ts`, `analyze-commits-title.js`, `backfill-release-notes.ts`, `propose-release-notes.ts`. - The `release` block in `apps/cli/package.json` and the smoke tests under `apps/cli/tests/`. diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index c2eaa7cdf1..c1ab14505e 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -13,6 +13,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import aiModels from "../../../.github/ai-models.json"; const { values } = parseArgs({ options: { @@ -20,7 +21,7 @@ const { values } = parseArgs({ "dry-run": { type: "boolean", default: false }, apply: { type: "boolean", default: false }, "render-only": { type: "boolean", default: false }, - model: { type: "string", default: "claude-haiku-5-5" }, + model: { type: "string", default: aiModels.claude.haiku }, }, strict: true, }); From a36a396f2668673f0e849b246d614d743879d0b0 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 11:29:20 +0000 Subject: [PATCH 02/11] ci(release): trigger sync repair when the AI review completes (#7114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync repair filtered AI Review completions with `workflow_run.name == 'AI Review'`, but `ai-review.yml` sets `run-name: AI Review #`, so `workflow_run.name` never matched. When the AI review finished after CI, repair skipped and only the hourly cron could restart it (seen on #7112). The filter now matches on the workflow file path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/workflows/sync-repair.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-repair.yml b/.github/workflows/sync-repair.yml index 530507652d..54d3030fb5 100644 --- a/.github/workflows/sync-repair.yml +++ b/.github/workflows/sync-repair.yml @@ -28,11 +28,11 @@ concurrency: jobs: repair: name: Repair with Claude - # AI Review runs are dispatched from the default branch, so their head branch is not the sync branch. + # AI Review runs are dispatched from the default branch, and their run name carries the PR number, so match the file. if: >- github.event_name != 'workflow_run' || github.event.workflow_run.head_branch == 'sync/develop-into-next' || - github.event.workflow_run.name == 'AI Review' + github.event.workflow_run.path == '.github/workflows/ai-review.yml' runs-on: ubuntu-latest timeout-minutes: 75 permissions: From 5f4ac9c91611b0685bf1c00bdaee63713ccd3061 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 11:29:54 +0000 Subject: [PATCH 03/11] fix(cli): name the failing engine operation in stack telemetry (#7113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR** When the new local stack fails because a container engine command failed, telemetry now says which step failed (create, start, inspect, engine target lookup, remove, or wait) instead of one shared `engine_command` fingerprint. Recent prereleases surface `engine_command` as the main cause of Docker `start` failures on Linux, but not which Docker call breaks, so the failure couldn't be narrowed down further. ### Before ```mermaid flowchart LR A["docker create / start / rm / ..."] -->|fails| B["ContainerError
operation"] B --> C["kind engine-command"] C --> D["…:engine_command"] ``` ### After ```mermaid flowchart LR A["docker create / start / rm / ..."] -->|fails| B["ContainerError
operation"] B --> C["kind engine-create,
engine-start, …"] C --> D["…:engine_command_create,
…:engine_command_start, …"] ``` ### What changed - `@supabase/stack` classifies a `ContainerError` with no finer cause by its engine operation: - `engine-create` ← `create` - `engine-start` ← `start` - `engine-inspect` ← `inspect`, `image`, `ps` - `engine-target` ← `info`, `system`, `context`, identity and connection checks - `engine-remove` ← `rm`, `stop`, `kill`, cleanup - `engine-wait` ← `wait` Any other operation stays `engine-command`. - The CLI maps each new kind to an `engine_command_` fingerprint suffix. The category stays `runtime_crash`, so category-level grouping is unchanged. - The kind already travels as an open string on every owner/client boundary, so there is no wire or schema change. A client that doesn't know a new kind reports it as unclassified, as it does today for any unknown kind. ## Linked issue None. Telemetry follow-up to the stack failure classification in #7044. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../shared/telemetry/error-actionability.ts | 12 +++ packages/stack/src/FailureKind.ts | 27 ++++++- packages/stack/src/FailureKind.unit.test.ts | 33 +++++++- ...oy.runtime-unavailable.integration.test.ts | 4 +- packages/stack/src/runtime/Container.ts | 78 ++++++++++--------- 5 files changed, 113 insertions(+), 41 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 5f2378ce3a..2e02b1749b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -104,6 +104,12 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "database_bootstrap", "docker_not_running", "engine_command", + "engine_command_create", + "engine_command_inspect", + "engine_command_remove", + "engine_command_start", + "engine_command_target", + "engine_command_wait", "engine_timeout", "filesystem", "filesystem_io", @@ -483,6 +489,12 @@ const stackFailureKindActionability = { }, "engine-timeout": { ...actionability.dockerNotRunning, fingerprint_suffix: "engine_timeout" }, "engine-command": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command" }, + "engine-create": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_create" }, + "engine-start": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_start" }, + "engine-inspect": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_inspect" }, + "engine-target": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_target" }, + "engine-remove": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_remove" }, + "engine-wait": { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_wait" }, "image-pull": { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, "port-conflict": { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }, "port-allocation": { ...actionability.runtimeCrash, fingerprint_suffix: "port_allocation" }, diff --git a/packages/stack/src/FailureKind.ts b/packages/stack/src/FailureKind.ts index 3ba1efafba..744b6ac17c 100644 --- a/packages/stack/src/FailureKind.ts +++ b/packages/stack/src/FailureKind.ts @@ -8,6 +8,12 @@ const stackFailureKinds = [ "engine-unavailable", "engine-timeout", "engine-command", + "engine-create", + "engine-start", + "engine-inspect", + "engine-target", + "engine-remove", + "engine-wait", "image-pull", "port-conflict", "port-allocation", @@ -64,6 +70,25 @@ const serviceOperationKinds: Readonly> = { compose: "configuration", }; +/** Engine operations grouped by lifecycle step; any other operation stays `engine-command`. */ +const engineOperationKinds: Readonly> = { + create: "engine-create", + start: "engine-start", + inspect: "engine-inspect", + image: "engine-inspect", + ps: "engine-inspect", + info: "engine-target", + system: "engine-target", + context: "engine-target", + identity: "engine-target", + connection: "engine-target", + rm: "engine-remove", + cleanup: "engine-remove", + stop: "engine-remove", + kill: "engine-remove", + wait: "engine-wait", +}; + const hostReasonKinds: Readonly> = { "connection-failure": "owner-connection", "owner-starting": "owner-startup", @@ -128,7 +153,7 @@ const classify = ( const inner = visit(value.cause, "engine"); // A pull's own deadline is the registry download, not a stalled engine. if (value.operation === "pull" && inner !== "engine-unavailable") return "image-pull"; - return inner ?? "engine-command"; + return inner ?? lookup(engineOperationKinds, value.operation) ?? "engine-command"; } case "ContainerLaunchError": case "ServiceLaunchError": diff --git a/packages/stack/src/FailureKind.unit.test.ts b/packages/stack/src/FailureKind.unit.test.ts index 5f5bbc39c1..b2a4452f66 100644 --- a/packages/stack/src/FailureKind.unit.test.ts +++ b/packages/stack/src/FailureKind.unit.test.ts @@ -77,8 +77,37 @@ const cases: ReadonlyArray error.message, ); -/** Runs one engine CLI invocation outside any pinned target, for resolving that target itself. */ +/** + * Runs one engine CLI invocation outside the owner's command runner, for resolving its target + * itself. `pin` precedes `args` so the failure names the subcommand, not a pin flag. + */ const runRaw = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], engine: ContainerEngine, args: ReadonlyArray, + pin: ReadonlyArray = [], ): Effect.Effect => Effect.scoped( Effect.gen(function* () { const child = yield* spawner.spawn( - ChildProcess.make(engine, args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }), + ChildProcess.make(engine, [...pin, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), ); const [stdout, stderr, code] = yield* Effect.all( [ @@ -257,12 +265,7 @@ const podmanInfo = ( argv: ReadonlyArray, ) => Effect.gen(function* () { - const output = yield* runRaw(spawner, "podman", [ - ...argv, - "info", - "--format", - PODMAN_INFO_FORMAT, - ]); + const output = yield* runRaw(spawner, "podman", ["info", "--format", PODMAN_INFO_FORMAT], argv); const [remote, rootless, hostname, ...graphRoot] = output.trim().split("|"); const root = graphRoot.join("|"); if (hostname === undefined || hostname.length === 0 || root.length === 0) @@ -326,12 +329,7 @@ export const resolveEngineTarget = Effect.fn("Container.resolveEngineTarget")(fu const argv: ReadonlyArray = Option.isSome(host) ? ["--host", host.value] : ["--context", yield* resolveContextName(spawner)]; - const daemonId = (yield* runRaw(spawner, "docker", [ - ...argv, - "info", - "--format", - "{{.ID}}", - ])).trim(); + const daemonId = (yield* runRaw(spawner, "docker", ["info", "--format", "{{.ID}}"], argv)).trim(); if (daemonId.length === 0) return yield* errorFor("identity", "Engine returned an empty id"); return { engine: "docker", argv, daemonId }; }); @@ -651,7 +649,7 @@ export const makeContainerRuntime = (options: { Effect.gen(function* () { const testRunLabel = yield* testRunLabelArgs; const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), + Effect.mapError((cause) => errorFor("token", cause)), ); // Not an instance container: no instance label, so it is never mistaken for one. const { name } = identifyContainer(spec, token, true); @@ -771,7 +769,7 @@ export const makeContainerRuntime = (options: { ) .pipe(Effect.mapError((cause) => errorFor("environment", cause))); const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), + Effect.mapError((cause) => errorFor("token", cause)), ); const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); const hostAlias = yield* hostAliasTarget(image, spec); @@ -1215,17 +1213,21 @@ const listStackContainers = Effect.fn("Container.listStackContainers")(function* readonly stackRoot: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const output = yield* runRaw(spawner, options.target.engine, [ - ...options.target.argv, - "ps", - "--all", - "--quiet", - "--no-trunc", - "--filter", - `label=com.supabase.stack=${options.stackId}`, - "--filter", - `label=com.supabase.stack-root=${options.stackRoot}`, - ]); + const output = yield* runRaw( + spawner, + options.target.engine, + [ + "ps", + "--all", + "--quiet", + "--no-trunc", + "--filter", + `label=com.supabase.stack=${options.stackId}`, + "--filter", + `label=com.supabase.stack-root=${options.stackRoot}`, + ], + options.target.argv, + ); return output .split("\n") .map((line) => line.trim()) @@ -1267,15 +1269,19 @@ export const listStackLabels = Effect.fn("Container.listStackLabels")(function* target: EngineTarget, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const output = yield* runRaw(spawner, target.engine, [ - ...target.argv, - "ps", - "--all", - "--filter", - "label=com.supabase.stack", - "--format", - '[{{json (.Label "com.supabase.stack")}},{{json (.Label "com.supabase.stack-root")}}]', - ]); + const output = yield* runRaw( + spawner, + target.engine, + [ + "ps", + "--all", + "--filter", + "label=com.supabase.stack", + "--format", + '[{{json (.Label "com.supabase.stack")}},{{json (.Label "com.supabase.stack-root")}}]', + ], + target.argv, + ); const labels = yield* Effect.forEach( output.split("\n").filter((line) => line.length > 0), (line) => decodeStackLabels(line).pipe(Effect.option), From 21396b5d2be0e8404a21f6d3a677fdaf70121150 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 13 Oct 2026 11:31:02 +0000 Subject: [PATCH 04/11] chore(cli): add permission building blocks for shared API calls (#7105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Scoped personal access tokens let a user pick exactly which permissions a token gets, so the CLI needs to say which permissions each command requires. This continues the mapping work from the command permission model; see [CLI-2474](https://linear.app/supabase/issue/CLI-2474/proposal-derive-and-document-the-management-api-scopes-each-cli). ## What's in this PR - Reusable permission building blocks for the Management API calls made by shared modules rather than command handlers: the DB config resolver, project ref picker, linked-project cache, link services, storage credentials, project API keys, upgrade suggestion, branch ref and target lookups, the login profile lookup, and linked service versions. Many commands reach these modules, so each is mapped once and included by the commands that use it instead of being repeated in every declaration. Each block lives next to the module whose calls it describes. - An integration test per block that runs the real module and checks the requests it makes against the block, so the mapping can't drift from the code. Command tests replace these modules with fakes, so they wouldn't catch it. Each best-effort call also has a 403 case showing the module's result survives, and the drift check verifies `request.query` qualifiers against the recorded requests. - Model additions the blocks needed: - `{ flag, is }` tests a switch by its effective value, so `--skip-pooler=false` is distinguished from omitting it. A flag given an empty value counts as absent. - `{ dbTarget }` conditions with `withDbTarget` select on the database a run targets: `--db-url`, then `--local`, then `--linked`, then the command's default. The DB config block applies only on the linked target. - `withKind` lets a command override a block's required or best-effort label. - `request.query` records request details such as `reveal=true` as data. The blocks live in shipped modules and run `block()` when those modules load, which adds a small amount of declaration data to the bundle and changes nothing else at runtime. No command includes them yet; later PRs map the commands that do. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../branch-ref.resolver.integration.test.ts | 94 ++++ .../command-internal/branch-ref.resolver.ts | 15 + .../branch-target.integration.test.ts | 68 +++ .../cli/src/command-internal/branch-target.ts | 6 + .../command-permissions.unit.test.ts | 44 +- .../command-permissions/model.ts | 98 +++- .../db-config.integration.test.ts | 456 +++++++++++++++++- .../src/command-internal/db-config.layer.ts | 45 ++ .../get-api-keys.integration.test.ts | 38 ++ apps/cli/src/command-internal/get-api-keys.ts | 10 + .../link-services-core.integration.test.ts | 163 +++++++ .../command-internal/link-services-core.ts | 11 + .../login-api.layer.integration.test.ts | 56 +++ .../src/command-internal/login-api.layer.ts | 6 + .../storage-credentials.integration.test.ts | 67 +++ .../command-internal/storage-credentials.ts | 11 + .../src/command-internal/upgrade-suggest.ts | 17 + .../upgrade-suggest.unit.test.ts | 42 +- .../project-ref.layer.integration.test.ts | 129 +++++ apps/cli/src/config/project-ref.layer.ts | 12 + .../src/shared/services/services.shared.ts | 15 + .../services/services.shared.unit.test.ts | 54 ++- .../linked-project-cache.integration.test.ts | 44 +- .../telemetry/linked-project-cache.layer.ts | 10 + apps/cli/tests/helpers/command-flags.ts | 32 +- .../tests/helpers/command-flags.unit.test.ts | 7 + apps/cli/tests/helpers/permission-drift.ts | 111 ++++- .../helpers/permission-drift.unit.test.ts | 92 ++++ apps/cli/tests/helpers/permission-registry.ts | 66 ++- .../helpers/permission-registry.unit.test.ts | 70 +++ 30 files changed, 1829 insertions(+), 60 deletions(-) create mode 100644 apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts create mode 100644 apps/cli/src/command-internal/branch-target.integration.test.ts create mode 100644 apps/cli/src/command-internal/get-api-keys.integration.test.ts create mode 100644 apps/cli/src/command-internal/link-services-core.integration.test.ts create mode 100644 apps/cli/src/command-internal/login-api.layer.integration.test.ts create mode 100644 apps/cli/src/command-internal/storage-credentials.integration.test.ts create mode 100644 apps/cli/src/config/project-ref.layer.integration.test.ts diff --git a/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts b/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts new file mode 100644 index 0000000000..ad7c498d97 --- /dev/null +++ b/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts @@ -0,0 +1,94 @@ +import type { V1GetABranchConfigOutput, V1GetABranchOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { jsonResponse, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; +import { mapped } from "./command-permissions/model.ts"; + +const branchRefDeclared = { _tag: "Declared", permissions: mapped(branchRef) } as const; + +const PARENT_REF = "abcdefghijklmnopqrst"; +const BRANCH_REF = "cccccccccccccccccccc"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; + +const DETAIL: typeof V1GetABranchConfigOutput.Type = { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "db.cccccccccccccccccccc.supabase.co", + db_port: 5432, +}; + +const FOUND: typeof V1GetABranchOutput.Type = { + id: BRANCH_UUID, + name: "feat-x", + project_ref: BRANCH_REF, + parent_project_ref: PARENT_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + with_data: false, +}; + +const mappers = { + mapGetError: () => Effect.die("unexpected branch-id lookup failure"), + mapFindError: () => Effect.die("unexpected branch-name lookup failure"), +}; + +function setup() { + return mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + request.url.includes("/v1/branches/") + ? jsonResponse(request, 200, DETAIL) + : jsonResponse(request, 200, FOUND), + ), + }); +} + +describe("resolveBranchProjectRef integration", () => { + it.effect("returns a project-ref-shaped input unchanged without calling the API", () => { + const api = setup(); + return Effect.gen(function* () { + const ref = yield* resolveBranchProjectRef(BRANCH_REF, PARENT_REF, mappers); + expect(ref).toBe(BRANCH_REF); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(api.layer)); + }); + + it.effect("resolves a branch UUID through the branch config lookup", () => { + const api = setup(); + return Effect.gen(function* () { + const ref = yield* resolveBranchProjectRef(BRANCH_UUID, PARENT_REF, mappers); + expect(ref).toBe(BRANCH_REF); + expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + `GET https://api.supabase.com/v1/branches/${BRANCH_UUID}`, + ]); + assertDriftAgainst(branchRefDeclared, { + command: "branch-ref block", + requests: api.requests, + }); + }).pipe(Effect.provide(api.layer)); + }); + + it.effect("resolves a branch name under its parent project through the branch lookup", () => { + const api = setup(); + return Effect.gen(function* () { + const ref = yield* resolveBranchProjectRef("feat-x", PARENT_REF, mappers); + expect(ref).toBe(BRANCH_REF); + expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PARENT_REF}/branches/feat-x`, + ]); + assertDriftAgainst(branchRefDeclared, { + command: "branch-ref block", + requests: api.requests, + }); + }).pipe(Effect.provide(api.layer)); + }); +}); diff --git a/apps/cli/src/command-internal/branch-ref.resolver.ts b/apps/cli/src/command-internal/branch-ref.resolver.ts index 2bd89f1aae..fe95984d8c 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.ts @@ -4,6 +4,21 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; +import { block } from "./command-permissions/model.ts"; + +/** `resolveBranchProjectRef`, for a branch argument that isn't a project ref. */ +export const branchRef = block("branch-ref", [ + { + operationId: "v1-get-a-branch-config", + kind: "required", + context: "the branch argument is a UUID", + }, + { + operationId: "v1-get-a-branch", + kind: "required", + context: "the branch argument is a branch name", + }, +]); /** * Per-family error mapping for {@link resolveBranchProjectRef}: each diff --git a/apps/cli/src/command-internal/branch-target.integration.test.ts b/apps/cli/src/command-internal/branch-target.integration.test.ts new file mode 100644 index 0000000000..ce7b45cecd --- /dev/null +++ b/apps/cli/src/command-internal/branch-target.integration.test.ts @@ -0,0 +1,68 @@ +import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { jsonResponse, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { branchTarget, findBranchName } from "./branch-target.ts"; +import { mapped } from "./command-permissions/model.ts"; + +const branchTargetDeclared = { _tag: "Declared", permissions: mapped(branchTarget) } as const; + +const PARENT_REF = "abcdefghijklmnopqrst"; +const BRANCH_REF = "cccccccccccccccccccc"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; + +const BRANCHES: typeof V1ListAllBranchesOutput.Type = [ + { + id: BRANCH_UUID, + name: "feat-x", + project_ref: BRANCH_REF, + parent_project_ref: PARENT_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + with_data: false, + }, +]; + +function setup(listing: { readonly status: number; readonly body: unknown }) { + const api = mockCommandPlatformApi({ + handler: (request) => Effect.sync(() => jsonResponse(request, listing.status, listing.body)), + }); + const out = mockOutput({ format: "text" }); + return { api, layer: Layer.mergeAll(api.layer, out.layer) }; +} + +describe("findBranchName integration", () => { + it.effect("lists the parent project's branches and returns the linked branch's name", () => { + const { api, layer } = setup({ status: 200, body: BRANCHES }); + return Effect.gen(function* () { + const name = yield* findBranchName(PARENT_REF, BRANCH_REF); + expect(name).toBe("feat-x"); + expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PARENT_REF}/branches`, + ]); + assertDriftAgainst(branchTargetDeclared, { + command: "branch-target block", + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("returns undefined without throwing when the listing request fails", () => { + const { api, layer } = setup({ status: 403, body: { message: "forbidden" } }); + return Effect.gen(function* () { + const name = yield* findBranchName(PARENT_REF, BRANCH_REF); + expect(api.requests).toHaveLength(1); + expect(name).toBeUndefined(); + assertDriftAgainst(branchTargetDeclared, { + command: "branch-target block", + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index dc914c1d1e..9b5e5ebf4f 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -10,6 +10,12 @@ import { type NetworkErrorFactory, type StatusErrorFactory, } from "./http-errors.ts"; +import { block } from "./command-permissions/model.ts"; + +/** `findBranchName`'s branch listing. */ +export const branchTarget = block("branch-target", [ + { operationId: "v1-list-all-branches", kind: "best-effort" }, +]); type BranchLookupBranches = typeof V1ListAllBranchesOutput.Type; diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 19f5fcff93..14b5a8934f 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -8,8 +8,9 @@ import type { TreeNode } from "../../../tests/helpers/command-flags.ts"; import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; import { readPermissions } from "./command-permissions.annotation.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-global-flags.ts"; -import type { CommandPermissions, FlagCondition, OperationEntry } from "./model.ts"; +import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; import { PENDING_COMMANDS } from "../../../tests/helpers/permission-pending-commands.ts"; +import { DB_TARGET_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; /** @@ -49,7 +50,11 @@ function rootGlobalFlagIds(): ReadonlySet { } function conditionFlagNames(entries: ReadonlyArray): ReadonlyArray { - return entries.flatMap((entry) => (entry.when ?? []).map((cond: FlagCondition) => cond.flag)); + return entries.flatMap((entry) => + (entry.when ?? []).flatMap((cond: EntryCondition) => + cond.dbTarget === undefined ? [cond.flag] : DB_TARGET_FLAGS, + ), + ); } function declarationOf(node: TreeNode): CommandPermissions | undefined { @@ -134,6 +139,21 @@ describe("command permission mapping completeness", () => { } for (const entry of permissions.operations) { for (const cond of entry.when ?? []) { + if (cond.dbTarget !== undefined) { + const where = `"${path}"'s \`when\` condition on "${entry.operationId}"`; + expect( + permissions.dbTargetDefault, + `${where} checks the database target, but the declaration sets no default — wrap it in withDbTarget`, + ).toBeDefined(); + // A block's condition may name a target the command can't select; a command's own must be reachable. + for (const target of cond.source === undefined ? cond.dbTarget : []) { + expect( + target === permissions.dbTargetDefault || node.flags.has(target), + `${where} names the "${target}" target, which --${target} can't select and isn't the default`, + ).toBe(true); + } + continue; + } // A condition a building block wrote (`cond.source` set) only has to name a real flag // somewhere in the CLI: a command can include a block without accepting every flag // its conditions mention. On such a command a presence condition is false and an @@ -144,15 +164,31 @@ describe("command permission mapping completeness", () => { `"${path}"'s \`when\` condition on "${entry.operationId}" names "--${cond.flag}", which doesn't exist${cond.source === undefined ? "" : ` (from building block "${cond.source}")`}`, ).toBe(true); const info = node.flagInfo.get(cond.flag); - if (cond.oneOf === undefined || info === undefined) continue; const where = `"${path}"'s \`when\` condition on "${entry.operationId}"`; + if (cond.is !== undefined && info !== undefined) { + expect( + info.isBoolean, + `${where} tests --${cond.flag} with \`is\`, but it takes a value — use \`oneOf\``, + ).toBe(true); + expect( + info.isOptional, + `${where} tests --${cond.flag} with \`is\`, but it is optional, so "not given" differs from "false" — use \`present\``, + ).toBe(false); + } + if (cond.is === undefined && cond.oneOf === undefined && info?.isBoolean === true) { + expect( + info.isOptional, + `${where} tests --${cond.flag} by presence, but it has a default and is always present — use \`is\``, + ).toBe(true); + } + if (cond.oneOf === undefined || info === undefined) continue; expect( cond.oneOf.length, `${where} lists no values for --${cond.flag}`, ).toBeGreaterThan(0); expect( info.isBoolean, - `${where} checks the value of --${cond.flag}, a switch with no value — use \`present\``, + `${where} checks the value of --${cond.flag}, a switch with no value — use \`is\` or \`present\``, ).toBe(false); for (const value of cond.oneOf) { expect( diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index a2291f0159..50a2e8735d 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -1,5 +1,9 @@ /** Serializable description of the Management API operations a command may call, keyed by the spec's `operationId`. */ +/** + * Whether a command's useful result depends on the call. `best-effort` means a 403 on the call + * still leaves the command's useful result; anything else is `required`. + */ type PermissionKind = "required" | "best-effort"; interface FlagConditionBase { @@ -10,12 +14,27 @@ interface FlagConditionBase { * by {@link withCondition}, must name a flag of the command it's declared on. */ readonly source?: string; + readonly dbTarget?: never; } -/** The flag must be present (or absent, when `present: false`) for an entry to apply. */ +/** + * The flag must be present (or absent, when `present: false`) for an entry to apply. A flag given + * an empty value counts as absent. + */ interface PresenceCondition extends FlagConditionBase { readonly present?: boolean; readonly oneOf?: never; + readonly is?: never; +} + +/** + * The switch's effective value, default included, must equal `is` for an entry to apply. A switch + * the including command lacks counts as `false`. + */ +interface SwitchCondition extends FlagConditionBase { + readonly is: boolean; + readonly present?: never; + readonly oneOf?: never; } /** @@ -25,19 +44,40 @@ interface PresenceCondition extends FlagConditionBase { interface ValueCondition extends FlagConditionBase { readonly oneOf: ReadonlyArray; readonly present?: never; + readonly is?: never; } -export type FlagCondition = PresenceCondition | ValueCondition; +export type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; + +/** The database a command talks to: `--local`, `--db-url`, or the linked project. */ +export type DbTarget = "local" | "db-url" | "linked"; + +/** + * The run's database target must be one of `dbTarget` for an entry to apply. The target is + * `--db-url` if given, else `--local`, else `--linked`, else the default set with + * {@link withDbTarget}. As with {@link FlagCondition}, `source` marks a building block's condition. + */ +interface DbTargetCondition { + readonly dbTarget: ReadonlyArray; + readonly source?: string; + readonly flag?: never; + readonly present?: never; + readonly is?: never; + readonly oneOf?: never; +} + +export type EntryCondition = FlagCondition | DbTargetCondition; export interface OperationEntry { /** The spec's `operationId`, e.g. `"v1-list-all-secrets"`. */ readonly operationId: string; readonly kind: PermissionKind; /** Every condition must hold for this entry to apply. */ - readonly when?: ReadonlyArray; + readonly when?: ReadonlyArray; /** A condition that isn't a flag, e.g. "only without a saved .temp/pooler-url". */ readonly context?: string; - readonly note?: string; + /** Request details a permission can depend on, e.g. `reveal=true` on the api-keys call. */ + readonly request?: { readonly query?: Readonly> }; /** The building block this entry came from (set by {@link block}); absent for a command's own entry. */ readonly source?: string; } @@ -47,6 +87,8 @@ export interface PermissionFragment { readonly operations: ReadonlyArray; /** Flags this fragment contributes that never change which operations it calls. */ readonly noApiEffectFlags?: ReadonlyArray; + /** Which database the command targets when no target flag is given; see {@link withDbTarget}. */ + readonly dbTargetDefault?: "local" | "linked"; } export type CommandPermissions = @@ -54,12 +96,16 @@ export type CommandPermissions = readonly status: "mapped"; readonly operations: ReadonlyArray; readonly noApiEffectFlags: ReadonlyArray; + readonly dbTargetDefault?: "local" | "linked"; } | { readonly status: "unmapped"; readonly reason: "go-delegated" }; /** - * Builds a named building block, tagging every entry and each of its conditions with `source` - * (see {@link FlagConditionBase.source}). + * Builds a named building block for Management API calls a shared module makes on behalf of its + * callers, tagging every entry and each of its conditions with `source` (see + * {@link FlagConditionBase.source}). Declare it next to the module that makes the calls; a command + * includes the blocks its handler reaches with {@link compose}, adding its own condition (e.g. + * `--linked`) with {@link withCondition} where the module is only used on that path. */ export function block( source: string, @@ -80,9 +126,17 @@ export function block( /** Merges fragments' operations and `noApiEffectFlags` into one, preserving entry order. */ export function compose(...fragments: ReadonlyArray): PermissionFragment { + const defaults = new Set(fragments.flatMap((fragment) => fragment.dbTargetDefault ?? [])); + if (defaults.size > 1) { + throw new Error( + `compose: fragments disagree on the default database target (${[...defaults]})`, + ); + } + const [dbTargetDefault] = defaults; return { operations: fragments.flatMap((fragment) => fragment.operations), noApiEffectFlags: fragments.flatMap((fragment) => fragment.noApiEffectFlags ?? []), + ...(dbTargetDefault === undefined ? {} : { dbTargetDefault }), }; } @@ -95,19 +149,49 @@ export function withCondition( condition: FlagCondition, ): PermissionFragment { return { + ...fragment, operations: fragment.operations.map((entry) => ({ ...entry, when: [condition, ...(entry.when ?? [])], })), - noApiEffectFlags: fragment.noApiEffectFlags, }; } +/** + * Gives every entry in `fragment` the `kind` the including command handles it with, when that + * differs from the block's own (e.g. a command that carries on after a 403 on a call its + * block treats as required). + * + * @public + */ +export function withKind(fragment: PermissionFragment, kind: PermissionKind): PermissionFragment { + return { + ...fragment, + operations: fragment.operations.map((entry) => ({ ...entry, kind })), + }; +} + +/** + * Sets the database a command targets when no `--db-url`, `--local` or `--linked` flag is given, + * for entries with a {@link DbTargetCondition}. + * + * @public + */ +export function withDbTarget( + fragment: PermissionFragment, + options: { readonly default: "local" | "linked" }, +): PermissionFragment { + return { ...fragment, dbTargetDefault: options.default }; +} + /** Builds a `"mapped"` {@link CommandPermissions} from a fragment, e.g. the result of {@link compose}. */ export function mapped(fragment: PermissionFragment): CommandPermissions { return { status: "mapped", operations: fragment.operations, noApiEffectFlags: fragment.noApiEffectFlags ?? [], + ...(fragment.dbTargetDefault === undefined + ? {} + : { dbTargetDefault: fragment.dbTargetDefault }), }; } diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 6ee18d352e..4180a26ba1 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -6,13 +6,16 @@ import { ConfigProvider, Effect, Exit, + Fiber, FileSystem, Layer, Option, Path, + Queue, Redacted, Stream, } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; @@ -41,6 +44,11 @@ import { } from "../../tests/helpers/mocks.ts"; import { VALID_TOKEN, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { unusedGateway } from "../../tests/helpers/unused-stack.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { applicableOperations } from "../../tests/helpers/permission-registry.ts"; +import type { ActiveFlags } from "../../tests/helpers/permission-registry.ts"; +import { dbConfig, dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; +import { mapped } from "./command-permissions/model.ts"; import { DebugFlag, DnsResolverFlag, @@ -50,9 +58,9 @@ import { } from "./global-flags.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; -import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; +import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { StackApi } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; @@ -76,6 +84,7 @@ function buildResolver( readonly projectHost?: string; readonly poolerHost?: string; readonly dbConnection?: Layer.Layer; + readonly debugLogger?: Layer.Layer; readonly configEnv?: Record; readonly stackApi?: Layer.Layer; } = {}, @@ -88,7 +97,7 @@ function buildResolver( projectId: Option.none(), }), opts.dbConnection ?? mockDbConnection, - mockDebugLogger, + opts.debugLogger ?? mockDebugLogger, mockOutput().layer, mockAnalytics().layer, mockTelemetryRuntime(), @@ -177,6 +186,22 @@ const linkedFlags: DbConfigFlags = { dnsResolver: "native", }; +const assertDbConfigDrift = ( + requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, + activeFlags: ActiveFlags, +) => + assertDriftAgainst( + { _tag: "Declared", permissions: mapped(dbConfig) }, + { + command: "db-config block", + activeFlags, + requests: requests.map((r) => ({ + method: r.method, + url: `https://api.supabase.com${r.path}`, + })), + }, + ); + describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () => Effect.gen(function* () { @@ -1229,6 +1254,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje expect(requests).toEqual([ { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, ]); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); }), ), Effect.ensuring( @@ -1346,6 +1372,137 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje expect(requests).toEqual([ { method: "GET", path: `/v1/projects/${targetRef}/config/database/pooler` }, ]); + assertDbConfigDrift(requests, { linked: true, "project-ref": targetRef }); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "an explicit --project-ref with no password mints a login role and clears network bans after three failed temp-role connects", + () => { + const ref = "banrecoveryrefabcdef"; + + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + let connectAttempts = 0; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const dbConnection = Layer.succeed(DbConnection, { + connect: () => + Effect.suspend(() => { + connectAttempts += 1; + return connectAttempts <= 3 + ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) + : Effect.succeed(session); + }), + }); + const json = (status: number, body: unknown) => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + const fetchMock = Object.assign( + (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { + return json(200, [ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]); + } + if (method === "POST" && url.pathname === `/v1/projects/${ref}/cli/login-role`) { + return json(201, { + role: "cli_login_role", + password: "temporary-role-password", + ttl_seconds: 3600, + }); + } + if (method === "POST" && url.pathname === `/v1/projects/${ref}/network-bans/retrieve`) { + return json(200, { banned_ipv4_addresses: ["203.0.113.7"] }); + } + if (method === "DELETE" && url.pathname === `/v1/projects/${ref}/network-bans`) { + return json(200, {}); + } + return json(404, { message: "unexpected request" }); + }, + { preconnect: previousFetch.preconnect }, + ); + + globalThis.fetch = fetchMock; + + return Effect.gen(function* () { + const retries = yield* Queue.unbounded(); + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => + message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, + http: () => Effect.void, + }); + const dir = yield* withWorkdir(); + const resolved = yield* resolve( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { + projectHost: "invalid", + dbConnection, + debugLogger, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }, + ).pipe(Effect.forkChild({ startImmediately: true })); + for (let failedConnects = 1; failedConnects <= 3; failedConnects++) { + yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(resolved))); + yield* TestClock.adjust("10 seconds"); + } + return yield* Fiber.join(resolved); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `cli_login_role.${ref}`, + password: "temporary-role-password", + }); + expect(connectAttempts).toBe(4); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + { method: "POST", path: `/v1/projects/${ref}/cli/login-role` }, + { method: "POST", path: `/v1/projects/${ref}/network-bans/retrieve` }, + { method: "DELETE", path: `/v1/projects/${ref}/network-bans` }, + ]); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); }), ), Effect.ensuring( @@ -1397,3 +1554,298 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, ); }); + +/** Replaces `globalThis.fetch` for the scope: `respond` answers a request, anything else gets a 404. */ +const stubApi = ( + requests: Array<{ readonly method: string; readonly path: string }>, + respond: (method: string, path: string) => Response | undefined, +) => + Effect.acquireRelease( + Effect.sync(() => { + const previousFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + return Promise.resolve( + respond(method, url.pathname) ?? + Response.json({ message: "unexpected request" }, { status: 404 }), + ); + }, + { preconnect: previousFetch.preconnect }, + ); + return previousFetch; + }), + (previousFetch) => + Effect.sync(() => { + globalThis.fetch = previousFetch; + }), + ); + +const primaryPooler = (ref: string) => [ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, +]; + +describe("dbConfigResolver (Management API calls)", () => { + const noDirectConnect = Layer.succeed(DbConnection, { + connect: () => Effect.die("unexpected connect()"), + }); + const noApiCalls = ( + requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, + activeFlags: ActiveFlags, + ) => { + expect(requests).toEqual([]); + expect( + applicableOperations( + { status: "mapped", operations: dbConfig.operations, noApiEffectFlags: [] }, + activeFlags, + ), + ).toEqual([]); + assertDbConfigDrift(requests, activeFlags); + }; + + it.effect("--local makes no Management API request and the block declares none", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubApi(requests, () => undefined); + const dir = yield* withWorkdir(); + const r = yield* resolve(dir, localFlags); + expect(r.isLocal).toBe(true); + noApiCalls(requests, { local: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("--db-url makes no Management API request and the block declares none", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubApi(requests, () => undefined); + const dir = yield* withWorkdir(); + const r = yield* resolve(dir, dbUrlFlags("postgres://alice:secret@example.com:6543/appdb")); + expect(r.isLocal).toBe(false); + noApiCalls(requests, { "db-url": true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect( + "a 403 on the pooler config fails with the IPv6 error when the direct host is unreachable", + () => { + const ref = "targetprojectrefabcd"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json({ message: "Forbidden" }, { status: 403 }) + : undefined, + ); + const dir = yield* withWorkdir(); + return yield* resolve( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { + projectHost: "invalid", + dbConnection: noDirectConnect, + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-password", + }, + }, + ); + }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("DbConfigIpv6Error"); + } + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect("an empty --project-ref still fetches the pooler config for the linked project", () => { + const ref = "emptyprojectrefabcde"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPooler(ref)) + : undefined, + ); + const dir = yield* withWorkdir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); + const r = yield* resolve( + dir, + { ...linkedFlags, linkedProjectRef: Option.some("") }, + { + projectHost: "invalid", + dbConnection: noDirectConnect, + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-password", + }, + }, + ); + expect(r.conn).toMatchObject({ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432 }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + assertDbConfigDrift(requests, { linked: true, "project-ref": "" }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("an empty --password still mints a login role", () => { + const ref = "emptypasswordrefabcd"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + return Effect.gen(function* () { + yield* stubApi(requests, (method, path) => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPooler(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }); + const dir = yield* withWorkdir(); + const r = yield* resolve( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("") }, + { + projectHost: "invalid", + dbConnection: Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }), + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-password", + }, + }, + ); + expect(r.conn).toMatchObject({ + user: `cli_login_role.${ref}`, + password: "temporary-role-password", + }); + expect(requests.map((request) => request.path)).toContain( + `/v1/projects/${ref}/cli/login-role`, + ); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref, password: "" }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + for (const forbidden of ["retrieve", "delete"] as const) { + it.effect( + `still connects with the login role when the network-ban ${forbidden} is forbidden`, + () => { + const ref = "banforbiddenrefabcde"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + let connectAttempts = 0; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const dbConnection = Layer.succeed(DbConnection, { + connect: () => + Effect.suspend(() => { + connectAttempts += 1; + return connectAttempts <= 3 + ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) + : Effect.succeed(session); + }), + }); + const forbiddenResponse = () => Response.json({ message: "Forbidden" }, { status: 403 }); + return Effect.gen(function* () { + yield* stubApi(requests, (method, path) => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPooler(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { + return forbidden === "retrieve" + ? forbiddenResponse() + : Response.json({ banned_ipv4_addresses: ["203.0.113.7"] }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return forbiddenResponse(); + } + return undefined; + }); + const retries = yield* Queue.unbounded(); + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => + message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, + http: () => Effect.void, + }); + const dir = yield* withWorkdir(); + const resolved = yield* resolve( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { + projectHost: "invalid", + dbConnection, + debugLogger, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }, + ).pipe(Effect.forkChild({ startImmediately: true })); + for (let failedConnects = 1; failedConnects <= 3; failedConnects++) { + yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(resolved))); + yield* TestClock.adjust("10 seconds"); + } + const r = yield* Fiber.join(resolved); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connectAttempts).toBe(4); + expect(requests).toContainEqual( + forbidden === "retrieve" + ? { method: "POST", path: `/v1/projects/${ref}/network-bans/retrieve` } + : { method: "DELETE", path: `/v1/projects/${ref}/network-bans` }, + ); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); + } +}); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f69ff9b868..9caec2ad57 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -41,6 +41,51 @@ import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; +import { block } from "./command-permissions/model.ts"; + +/** + * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands + * include it with `withDbTarget`. `gen types --project-id` and `db dump`'s container fallback + * fetch the pooler config without `--project-ref`, so those commands declare that entry + * themselves. + */ +export const dbConfig = block("db-config", [ + { + operationId: "v1-create-login-role", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: + "no database password from --password (an empty value counts as none), SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "project-ref" }], + context: + "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "project-ref", oneOf: [""] }], + context: + "an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", + }, + { + operationId: "v1-list-all-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: + "after three failed attempts to connect through the pooler as the temporary login role", + }, + { + operationId: "v1-delete-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: + "after three failed attempts to connect through the pooler as the temporary login role", + }, +]); const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); diff --git a/apps/cli/src/command-internal/get-api-keys.integration.test.ts b/apps/cli/src/command-internal/get-api-keys.integration.test.ts new file mode 100644 index 0000000000..4e0aa5d2d4 --- /dev/null +++ b/apps/cli/src/command-internal/get-api-keys.integration.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { VALID_REF, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { getProjectApiKeys, projectApiKeys } from "./get-api-keys.ts"; +import { mapped } from "./command-permissions/model.ts"; + +const declared = { _tag: "Declared", permissions: mapped(projectApiKeys) } as const; +const apiKeys = [{ name: "anon", api_key: "anon-key", type: "legacy" }]; +const apiKeysUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/api-keys`; + +describe("getProjectApiKeys", () => { + it.live("requests the project api-keys with reveal=true only when asked for secret keys", () => + Effect.gen(function* () { + const revealed = mockCommandPlatformApi({ response: { status: 200, body: apiKeys } }); + const plain = mockCommandPlatformApi({ response: { status: 200, body: apiKeys } }); + + const keys = yield* getProjectApiKeys(VALID_REF, true).pipe( + Effect.provide(revealed.factoryLayer), + ); + yield* getProjectApiKeys(VALID_REF).pipe(Effect.provide(plain.factoryLayer)); + + expect(keys).toEqual(apiKeys); + expect(revealed.requests.map((request) => request.urlParams)).toEqual(["reveal=true"]); + expect(plain.requests.map((request) => request.urlParams)).toEqual([""]); + for (const { requests } of [revealed, plain]) { + expect(requests.map((request) => request.url)).toEqual([apiKeysUrl]); + assertDriftAgainst(declared, { + command: "project-api-keys block", + activeFlags: {}, + requests, + exact: true, + }); + } + }), + ); +}); diff --git a/apps/cli/src/command-internal/get-api-keys.ts b/apps/cli/src/command-internal/get-api-keys.ts index 9f3889a440..20557bba28 100644 --- a/apps/cli/src/command-internal/get-api-keys.ts +++ b/apps/cli/src/command-internal/get-api-keys.ts @@ -7,6 +7,16 @@ import { ProjectsApiKeysUnexpectedStatusError, } from "../commands/projects/projects.errors.ts"; import { mapHttpError } from "./http-errors.ts"; +import { block } from "./command-permissions/model.ts"; + +/** + * `getProjectApiKeys`, shared by `projects api-keys` and `bootstrap`. Only a caller that asks + * for secret keys sends `reveal=true` (`projects api-keys --reveal`), so that qualifier belongs + * on the command's own entry. + */ +export const projectApiKeys = block("project-api-keys", [ + { operationId: "v1-get-project-api-keys", kind: "required" }, +]); type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; diff --git a/apps/cli/src/command-internal/link-services-core.integration.test.ts b/apps/cli/src/command-internal/link-services-core.integration.test.ts new file mode 100644 index 0000000000..34079e1b8a --- /dev/null +++ b/apps/cli/src/command-internal/link-services-core.integration.test.ts @@ -0,0 +1,163 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import { + VALID_REF, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, +} from "../../tests/helpers/command-mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { linkServices, linkServicesCore } from "./link-services-core.ts"; +import { mapped } from "./command-permissions/model.ts"; + +const declared = { _tag: "Declared", permissions: mapped(linkServices) } as const; +const storageConfigUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/config/storage`; +const poolerConfigUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/config/database/pooler`; + +const storageConfig = { + fileSizeLimit: 52428800, + features: { + imageTransformation: { enabled: true }, + s3Protocol: { enabled: true }, + purgeCache: { enabled: false }, + icebergCatalog: { enabled: false, maxNamespaces: 10, maxTables: 10, maxCatalogs: 2 }, + vectorBuckets: { enabled: false, maxBuckets: 10, maxIndexes: 10 }, + }, + capabilities: { list_v2: true, iceberg_catalog: false, object_versioning: false }, + external: { upstreamTarget: "main" }, + migrationVersion: "20260101000000", +}; +const poolerConfigs = [ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${VALID_REF}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${VALID_REF}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, +]; + +describe("linkServicesCore", () => { + const workdir = useTempWorkdir("link-services-"); + + const run = ( + skipPooler: boolean, + handler?: ( + request: HttpClientRequest.HttpClientRequest, + ) => { readonly status: number; readonly body: unknown } | undefined, + ) => { + const api = mockCommandPlatformApi( + handler === undefined + ? {} + : { + handler: (request) => { + const reply = handler(request) ?? { status: 404, body: { message: "not found" } }; + return Effect.succeed(jsonResponse(request, reply.status, reply.body)); + }, + }, + ); + return linkServicesCore({ + ref: VALID_REF, + serviceKey: "service-key", + skipPooler, + workdir: workdir.current, + }).pipe( + Effect.provide( + Layer.mergeAll( + api.layer, + api.httpClientLayer, + mockCommandSettings({ workdir: workdir.current }), + ).pipe(Layer.provideMerge(BunServices.layer)), + ), + Effect.as(api.requests), + ); + }; + + it.live("requests the storage and pooler configs of the linked project", () => + Effect.gen(function* () { + const requests = yield* run(false); + + const urls = requests.map((request) => request.url); + expect(urls).toContain(storageConfigUrl); + expect(urls).toContain(poolerConfigUrl); + assertDriftAgainst(declared, { + command: "link-services block", + activeFlags: { "skip-pooler": false }, + requests, + }); + }), + ); + + it.live("writes the storage migration version and the session-mode pooler URL", () => + Effect.gen(function* () { + const requests = yield* run(false, (request) => + request.url === storageConfigUrl + ? { status: 200, body: storageConfig } + : request.url === poolerConfigUrl + ? { status: 200, body: poolerConfigs } + : undefined, + ); + + const fs = yield* FileSystem.FileSystem; + const temp = `${workdir.current}/supabase/.temp`; + expect( + requests + .map((request) => request.url) + .filter((url) => url.startsWith("https://api.supabase.com")), + ).toEqual([storageConfigUrl, poolerConfigUrl]); + expect(yield* fs.readFileString(`${temp}/storage-migration`)).toBe("20260101000000"); + expect(yield* fs.readFileString(`${temp}/pooler-url`)).toBe( + `postgres://postgres.${VALID_REF}@aws-0-us-east-1.pooler.supabase.com:5432/postgres`, + ); + assertDriftAgainst(declared, { + command: "link-services block", + activeFlags: { "skip-pooler": false }, + requests, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("writes nothing and still completes when both config lookups are forbidden", () => + Effect.gen(function* () { + const requests = yield* run(false, (request) => + request.url === storageConfigUrl || request.url === poolerConfigUrl + ? { status: 403, body: { message: "Forbidden" } } + : undefined, + ); + + const fs = yield* FileSystem.FileSystem; + const temp = `${workdir.current}/supabase/.temp`; + expect(requests.map((request) => request.url)).toEqual( + expect.arrayContaining([storageConfigUrl, poolerConfigUrl]), + ); + expect(yield* fs.exists(`${temp}/storage-migration`)).toBe(false); + expect(yield* fs.exists(`${temp}/pooler-url`)).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("requests no pooler config when skipPooler is set", () => + Effect.gen(function* () { + const requests = yield* run(true); + + const urls = requests.map((request) => request.url); + expect(urls).toContain(storageConfigUrl); + expect(urls).not.toContain(poolerConfigUrl); + assertDriftAgainst(declared, { + command: "link-services block", + activeFlags: { "skip-pooler": true }, + requests, + }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index 461162dafa..7f72794b89 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -10,6 +10,17 @@ import { fetchPostgrestVersion, fetchStorageVersion, } from "./tenant-versions.ts"; +import { block } from "./command-permissions/model.ts"; + +/** `linkServicesCore`, shared by `link` and `bootstrap`. */ +export const linkServices = block("link-services", [ + { operationId: "v1-get-storage-config", kind: "best-effort" }, + { + operationId: "v1-get-pooler-config", + kind: "best-effort", + when: [{ flag: "skip-pooler", is: false }], + }, +]); export interface LinkServicesInput { readonly ref: string; diff --git a/apps/cli/src/command-internal/login-api.layer.integration.test.ts b/apps/cli/src/command-internal/login-api.layer.integration.test.ts new file mode 100644 index 0000000000..cbb2857f58 --- /dev/null +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { LoginApi } from "../commands/login/login-api.service.ts"; +import { + DEFAULT_API_URL, + VALID_TOKEN, + mockCommandPlatformApi, + mockCommandSettings, +} from "../../tests/helpers/command-mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; +import { mapped } from "./command-permissions/model.ts"; + +const declared = { _tag: "Declared", permissions: mapped(loginProfile) } as const; + +describe("loginApiLayer", () => { + it.effect("fetches the account identity from GET /v1/profile after login", () => { + const api = mockCommandPlatformApi({ + response: { status: 200, body: { gotrue_id: "gotrue-user-123" } }, + }); + const layer = loginApiLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), + ); + return Effect.gen(function* () { + const loginApi = yield* LoginApi; + const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); + expect(gotrueId).toEqual(Option.some("gotrue-user-123")); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/profile", + ]); + assertDriftAgainst(declared, { + command: "login-profile block", + activeFlags: {}, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the identity stitch when the profile lookup is forbidden", () => { + const api = mockCommandPlatformApi({ + response: { status: 403, body: { message: "Forbidden" } }, + }); + const layer = loginApiLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), + ); + return Effect.gen(function* () { + const loginApi = yield* LoginApi; + const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); + expect(gotrueId).toEqual(Option.none()); + expect(api.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 6a6a994b1e..2eb81c228d 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -5,6 +5,12 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { CommandSettings } from "../config/command-settings.service.ts"; import { LoginApi, type LoginApiSessionResponse } from "../commands/login/login-api.service.ts"; import { LoginVerificationError } from "../commands/login/login.errors.ts"; +import { block } from "./command-permissions/model.ts"; + +/** The profile lookup that stitches the account identity after login. */ +export const loginProfile = block("login-profile", [ + { operationId: "v1-get-profile", kind: "best-effort" }, +]); const POLL_TIMEOUT = "10 seconds"; diff --git a/apps/cli/src/command-internal/storage-credentials.integration.test.ts b/apps/cli/src/command-internal/storage-credentials.integration.test.ts new file mode 100644 index 0000000000..ddd3633ee7 --- /dev/null +++ b/apps/cli/src/command-internal/storage-credentials.integration.test.ts @@ -0,0 +1,67 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Layer } from "effect"; + +import { + VALID_REF, + mockCommandPlatformApi, + mockCommandSettings, +} from "../../tests/helpers/command-mocks.ts"; +import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; +import { mapped } from "./command-permissions/model.ts"; +import { resolveStorageCredentials, storageCredentials } from "./storage-credentials.ts"; + +const declared = { _tag: "Declared", permissions: mapped(storageCredentials) } as const; +const apiKeys = [ + { name: "anon", api_key: "anon-key", type: "legacy" }, + { name: "service_role", api_key: "service-role-key", type: "legacy" }, +]; +const config = { api: { enabled: true, port: 54321, tls: { enabled: false } }, auth: {} }; + +describe("resolveStorageCredentials for a remote project", () => { + const resolve = ( + api: ReturnType, + env: Record = {}, + ) => + resolveStorageCredentials({ projectRef: VALID_REF, config }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env)), + api.factoryLayer, + mockCommandSettings({ workdir: "/unused" }), + mockRuntimeInfo(), + unusedStackServices, + BunServices.layer, + ), + ), + ); + + it.live("fetches the service-role key from the project api-keys without an env override", () => { + const api = mockCommandPlatformApi({ response: { status: 200, body: apiKeys } }); + return Effect.gen(function* () { + const credentials = yield* resolve(api); + + expect(credentials.apiKey).toBe("service-role-key"); + expect(api.requests.map((request) => request.urlParams)).toEqual(["reveal=true"]); + assertDriftAgainst(declared, { + command: "storage-credentials block", + activeFlags: {}, + requests: api.requests, + }); + }); + }); + + it.live("requests no api-keys when SUPABASE_AUTH_SERVICE_ROLE_KEY is set", () => { + const api = mockCommandPlatformApi({ response: { status: 200, body: apiKeys } }); + return Effect.gen(function* () { + const credentials = yield* resolve(api, { + SUPABASE_AUTH_SERVICE_ROLE_KEY: "env-service-role-key", + }); + + expect(credentials.apiKey).toBe("env-service-role-key"); + expect(api.requests).toEqual([]); + }); + }); +}); diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index 18c4f01e0d..0b4158db5f 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -26,6 +26,17 @@ import { } from "./storage-credentials.errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { stackStorageEndpoint } from "./stack-storage.ts"; +import { block } from "./command-permissions/model.ts"; + +/** `resolveStorageCredentials` for a remote project. */ +export const storageCredentials = block("storage-credentials", [ + { + operationId: "v1-get-project-api-keys", + kind: "required", + context: "a remote project and no SUPABASE_AUTH_SERVICE_ROLE_KEY", + request: { query: { reveal: "true" } }, + }, +]); /** * Resolves Storage gateway credentials (base URL, service-role key, and local diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index 51d5af061b..e927d5dbcc 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -17,6 +17,23 @@ import { } from "../shared/telemetry/event-catalog.ts"; import { Output } from "../shared/output/output.service.ts"; import { billingUrl } from "./profile.ts"; +import { block } from "./command-permissions/model.ts"; + +/** `suggestUpgrade`'s entitlement lookup. */ +export const upgradeSuggestion = block("upgrade-suggest", [ + { + operationId: "v1-get-project", + kind: "best-effort", + context: + "after a 4xx response that carries no plan-gate error, at a call site that names a feature", + }, + { + operationId: "v1-get-organization-entitlements", + kind: "best-effort", + context: + "after a 4xx response that carries no plan-gate error, at a call site that names a feature", + }, +]); function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index d6192e11db..9f38ecb843 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -13,10 +13,17 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; -import { suggestUpgrade } from "./upgrade-suggest.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { suggestUpgrade, upgradeSuggestion } from "./upgrade-suggest.ts"; +import { mapped } from "./command-permissions/model.ts"; const ORG_SLUG = "test-org"; +const upgradeSuggestionDeclared = { + _tag: "Declared", + permissions: mapped(upgradeSuggestion), +} as const; + function projectResponse(slug: string = ORG_SLUG) { return { id: VALID_REF, @@ -130,27 +137,36 @@ describe("suggestUpgrade", () => { }).pipe(Effect.provide(layer)); }); - it.live("skips when getProject fails", () => { - const { layer, analytics, out } = setup({ projectStatus: 500 }); + it.live("skips when getProject is forbidden", () => { + const { layer, analytics, out, api } = setup({ projectStatus: 403 }); return Effect.gen(function* () { - yield* suggestUpgrade({ + const suggested = yield* suggestUpgrade({ projectRef: VALID_REF, featureKey: "branching_limit", statusCode: 402, }); + expect(suggested).toBe(false); + expect(api.requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); expect(analytics.captured).toHaveLength(0); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); - it.live("skips when getEntitlements fails", () => { - const { layer, analytics, out } = setup({ entitlementsStatus: 500 }); + it.live("skips when getEntitlements is forbidden", () => { + const { layer, analytics, out, api } = setup({ entitlementsStatus: 403 }); return Effect.gen(function* () { - yield* suggestUpgrade({ + const suggested = yield* suggestUpgrade({ projectRef: VALID_REF, featureKey: "branching_limit", statusCode: 402, }); + expect(suggested).toBe(false); + expect(api.requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + `GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`, + ]); expect(analytics.captured).toHaveLength(0); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); @@ -210,7 +226,7 @@ describe("suggestUpgrade", () => { }); it.live("fires cli_upgrade_suggested with {feature_key, org_slug} when gated", () => { - const { layer, analytics, out } = setup(); + const { layer, analytics, out, api } = setup(); return Effect.gen(function* () { yield* suggestUpgrade({ projectRef: VALID_REF, @@ -225,6 +241,14 @@ describe("suggestUpgrade", () => { ]); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/test-org/billing"); + expect(api.requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + `GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`, + ]); + assertDriftAgainst(upgradeSuggestionDeclared, { + command: "upgrade-suggest block", + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -278,7 +302,6 @@ describe("suggestUpgrade", () => { statusCode: 402, response: gateResponse(402, ENVELOPE_BODY), }); - expect(api.requests).toHaveLength(0); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -287,6 +310,7 @@ describe("suggestUpgrade", () => { properties: { feature_key: "branching_persistent", org_slug: "env-org" }, }, ]); + expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/config/project-ref.layer.integration.test.ts b/apps/cli/src/config/project-ref.layer.integration.test.ts new file mode 100644 index 0000000000..823b63e9ba --- /dev/null +++ b/apps/cli/src/config/project-ref.layer.integration.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { + VALID_REF, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, +} from "../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { projectRefLayer, projectRefPicker } from "./project-ref.layer.ts"; +import { mapped } from "../command-internal/command-permissions/model.ts"; +import { ProjectRefResolver } from "./project-ref.service.ts"; + +type Projects = typeof V1ListAllProjectsOutput.Type; + +const PROJECT: Projects[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const declared = { _tag: "Declared", permissions: mapped(projectRefPicker) } as const; + +describe("projectRefLayer Management API requests", () => { + const workdir = useTempWorkdir("project-ref-api-"); + + function setup(opts: { readonly projectId?: string }) { + const api = mockCommandPlatformApi({ response: { status: 200, body: [PROJECT] } }); + const output = mockOutput({ promptSelectResponses: [VALID_REF] }); + const layer = projectRefLayer.pipe( + Layer.provide( + mockCommandSettings({ + workdir: workdir.current, + projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), + }), + ), + Layer.provide(mockTty({ stdinIsTty: true })), + Layer.provide(output.layer), + Layer.provide(api.factoryLayer), + Layer.provide(BunServices.layer), + ); + return { api, layer }; + } + + it.effect("lists projects for the interactive picker when no ref is configured", () => { + const { api, layer } = setup({}); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + ]); + assertDriftAgainst(declared, { + command: "project-ref picker block", + activeFlags: {}, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker when --project-ref is empty", () => { + const { api, layer } = setup({}); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + const ref = yield* resolve(Option.some("")); + expect(ref).toBe(VALID_REF); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + ]); + assertDriftAgainst(declared, { + command: "project-ref picker block", + activeFlags: { "project-ref": "" }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("requests nothing when --project-ref names the project", () => { + const { api, layer } = setup({}); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + const ref = yield* resolve(Option.some(VALID_REF)); + expect(ref).toBe(VALID_REF); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("requests nothing when SUPABASE_PROJECT_ID names the project", () => { + const { api, layer } = setup({ projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("requests nothing when supabase/.temp/project-ref links the project", () => { + const tempDir = join(workdir.current, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "project-ref"), VALID_REF); + const { api, layer } = setup({}); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + const ref = yield* resolve(Option.none()); + expect(ref).toBe(VALID_REF); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 003c75494b..1945cfdc84 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -16,6 +16,18 @@ import { PROJECT_NOT_LINKED_MESSAGE, PROJECT_REF_PATTERN, } from "./project-ref.service.ts"; +import { block } from "../command-internal/command-permissions/model.ts"; + +/** `ProjectRefResolver`'s interactive picker, reached only when no ref comes from a flag, env or the linked workdir. */ +export const projectRefPicker = block("project-ref", [ + { + operationId: "v1-list-all-projects", + kind: "required", + when: [{ flag: "project-ref", present: false }], + context: + "an interactive terminal, no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", + }, +]); function assertValid(ref: string): Effect.Effect { if (PROJECT_REF_PATTERN.test(ref)) { diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 4fb0c54a86..c89c9770df 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -22,6 +22,21 @@ import { slimImageForAlias, slimImageForCurrentPin, } from "./slim-images.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; + +/** `fetchLinkedServiceVersions`, behind `services`. */ +export const linkedServiceVersions = block("linked-service-versions", [ + { + operationId: "v1-get-project-api-keys", + kind: "best-effort", + request: { query: { reveal: "true" } }, + }, + { + operationId: "v1-get-project", + kind: "best-effort", + context: "the api-keys lookup returned a usable key", + }, +]); export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 90af5802a3..30b415bd1c 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -3,9 +3,12 @@ import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/http"; import { afterEach, beforeEach, vi } from "vitest"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; +import { assertDriftAgainst } from "../../../tests/helpers/permission-drift.ts"; +import { mapped } from "../../command-internal/command-permissions/model.ts"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { fetchLinkedServiceVersions, + linkedServiceVersions, listLocalServiceVersions, localServiceImagesFromDockerfile, mergeRemoteServiceVersions, @@ -58,6 +61,35 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; const runLinkedFetch = (input: Parameters[0]) => fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); +const linkedServiceVersionsDeclared = { + _tag: "Declared", + permissions: mapped(linkedServiceVersions), +} as const; + +// Tenant probes share the test server's origin, so only Management API paths are recorded. +const recordManagementRequests = () => { + const requests: Array<{ readonly method: string; readonly url: string }> = []; + return { + requests, + routes: () => [...new Set(requests.map((r) => `${r.method} ${new URL(r.url).pathname}`))], + record: (request: Request) => { + if (new URL(request.url).pathname.startsWith("/v1/")) { + requests.push({ method: request.method, url: request.url }); + } + }, + }; +}; + +const assertLinkedServiceVersionsDrift = ( + requests: ReadonlyArray<{ readonly method: string; readonly url: string }>, + apiUrl: string, +) => + assertDriftAgainst(linkedServiceVersionsDeclared, { + command: "linked-service-versions block", + requests, + apiUrl, + }); + const serve = (options: Parameters[0]) => Effect.acquireRelease( Effect.try(() => Bun.serve(options)), @@ -298,9 +330,11 @@ describe("services shared", () => { it.live("returns postgres only when no service-role key is available", () => Effect.gen(function* () { + const managementApi = recordManagementRequests(); const server = yield* serve({ port: 0, fetch(request) { + managementApi.record(request); const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}`) { return Response.json({ @@ -357,17 +391,24 @@ describe("services shared", () => { }); expect(result).toEqual({ postgres: "17.6.1.200" }); + expect(managementApi.routes()).toEqual([ + `GET /v1/projects/${PROJECT_REF}/api-keys`, + `GET /v1/projects/${PROJECT_REF}`, + ]); + assertLinkedServiceVersionsDrift(managementApi.requests, server.url.origin); }), ); it.live("returns no linked versions when project api keys cannot be loaded", () => Effect.gen(function* () { + const managementApi = recordManagementRequests(); const server = yield* serve({ port: 0, fetch(request) { + managementApi.record(request); const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return new Response("boom", { status: 500 }); + return Response.json({ message: "Forbidden" }, { status: 403 }); } if (url.pathname === `/v1/projects/${PROJECT_REF}`) { @@ -403,17 +444,21 @@ describe("services shared", () => { }); expect(result).toEqual({}); + expect(managementApi.routes()).toEqual([`GET /v1/projects/${PROJECT_REF}/api-keys`]); + assertLinkedServiceVersionsDrift(managementApi.requests, server.url.origin); }), ); it.live("still returns tenant service versions when project version lookup fails", () => Effect.gen(function* () { + const managementApi = recordManagementRequests(); const server = yield* serve({ port: 0, fetch(request) { + managementApi.record(request); const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); + return Response.json({ message: "Forbidden" }, { status: 403 }); } if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { @@ -459,6 +504,11 @@ describe("services shared", () => { postgrest: "v14.13", storage: "v1.61.0", }); + expect(managementApi.routes()).toEqual([ + `GET /v1/projects/${PROJECT_REF}/api-keys`, + `GET /v1/projects/${PROJECT_REF}`, + ]); + assertLinkedServiceVersionsDrift(managementApi.requests, server.url.origin); }), ); diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index 5e09626a17..cfc473f55a 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -13,10 +13,14 @@ import { mockCommandCredentialsLayer, mockCommandPlatformApi, } from "../../tests/helpers/command-mocks.ts"; +import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { linkedProjectCache, linkedProjectCacheLayer } from "./linked-project-cache.layer.ts"; +import { mapped } from "../command-internal/command-permissions/model.ts"; import { identityStitchLayer } from "../command-internal/identity-stitch.ts"; -import { linkedProjectCacheLayer } from "./linked-project-cache.layer.ts"; import { LinkedProjectCache } from "./linked-project-cache.service.ts"; +const declared = { _tag: "Declared", permissions: mapped(linkedProjectCache) } as const; + describe("linkedProjectCacheLayer", () => { it.live( "stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", @@ -73,6 +77,12 @@ describe("linkedProjectCacheLayer", () => { return Effect.gen(function* () { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toHaveLength(1); + assertDriftAgainst(declared, { + command: "linked-project-cache block", + activeFlags: {}, + requests: api.requests, + }); expect(JSON.stringify(analytics.aliased)).toContain("gotrue-abc"); const written: unknown = JSON.parse( readFileSync(join(workdir, "supabase", ".temp", "linked-project.json"), "utf8"), @@ -129,6 +139,7 @@ describe("linkedProjectCacheLayer", () => { return Effect.gen(function* () { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toEqual([]); expect(analytics.groupIdentified).toEqual([]); expect(analytics.aliased).toEqual([]); rmSync(workdir, { recursive: true, force: true }); @@ -174,4 +185,35 @@ describe("linkedProjectCacheLayer", () => { }).pipe(Effect.provide(layer)); }, ); + + it.live("leaves the command's result alone when the project lookup is forbidden", () => { + const workdir = mkdtempSync(join(tmpdir(), "linked-cache-forbidden-")); + const analytics = mockAnalytics(); + const api = mockCommandPlatformApi({ + response: { status: 403, body: { message: "Forbidden" } }, + }); + const identityStitch = identityStitchLayer.pipe( + Layer.provide(analytics.layer), + Layer.provide( + mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), + ), + Layer.provide(BunServices.layer), + ); + const layer = linkedProjectCacheLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir })), + Layer.provide(mockCommandCredentialsLayer), + Layer.provide(identityStitch), + Layer.provide(analytics.layer), + Layer.provide(BunServices.layer), + ); + return Effect.gen(function* () { + const cache = yield* LinkedProjectCache; + yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toHaveLength(1); + expect(existsSync(join(workdir, "supabase", ".temp", "linked-project.json"))).toBe(false); + expect(analytics.groupIdentified).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index b1ab675549..406ebc6e4f 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -9,6 +9,16 @@ import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { GroupOrganization, GroupProject } from "../shared/telemetry/event-catalog.ts"; import { readProjectRefFile, tempPaths } from "../command-internal/temp-paths.ts"; import { LinkedProjectCache } from "./linked-project-cache.service.ts"; +import { block } from "../command-internal/command-permissions/model.ts"; + +/** `LinkedProjectCache`'s post-run fill. */ +export const linkedProjectCache = block("linked-project-cache", [ + { + operationId: "v1-get-project", + kind: "best-effort", + context: "no cached supabase/.temp/linked-project.json", + }, +]); function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { diff --git a/apps/cli/tests/helpers/command-flags.ts b/apps/cli/tests/helpers/command-flags.ts index 142472bfe6..4758cf7417 100644 --- a/apps/cli/tests/helpers/command-flags.ts +++ b/apps/cli/tests/helpers/command-flags.ts @@ -7,45 +7,53 @@ import { flattenSubcommands, userGlobalFlagParams, } from "../../src/docs/docs-introspection.ts"; -import { unwrapToSingleParam } from "../../src/command-internal/param-introspection.ts"; +import { unwrapParam } from "../../src/command-internal/param-introspection.ts"; import type { PermissionVariant } from "./permission-registry.ts"; interface FlagInfo { - /** A switch takes no value, so only a presence condition can test it. */ + /** A switch takes no value: test it with `is` when it has a default, `present` when it's optional. */ readonly isBoolean: boolean; + /** + * Declared with `Flag.optional` as the outermost wrapper, so the handler can tell "not given" + * from a value. A `Flag.withDefault` flag is not (it maps the optional), and neither is an + * optional flag a later `Flag.map` wraps: apply `Flag.optional` last on a flag tested with `present`. + */ + readonly isOptional: boolean; /** The values a choice flag accepts; `undefined` for a free-form value. */ readonly choices: ReadonlyArray | undefined; } -function singleParamsOf( - params: ReadonlyArray, -): ReadonlyArray>> { - return (params as Array[0]>).map((param) => { - const single = unwrapToSingleParam(param); - if (single === undefined) { +function unwrappedParamsOf(params: ReadonlyArray): ReadonlyArray<{ + single: NonNullable>["single"]; + isOptional: boolean; +}> { + return (params as Array[0]>).map((param) => { + const unwrapped = unwrapParam(param); + if (unwrapped === undefined) { throw new Error( "command-flags.ts: a flag param wraps an unrecognized Param variant — effect's Param union may have changed.", ); } - return single; + return { single: unwrapped.single, isOptional: param._tag === "Optional" }; }); } export function flagNames(params: ReadonlyArray): ReadonlyArray { - return singleParamsOf(params).map((single) => single.name); + return unwrappedParamsOf(params).map(({ single }) => single.name); } /** A command's own flags: `config.flags`, `contextConfig.flags` (`withSharedFlags`), and whatever it registers via `withGlobalFlags` (e.g. `seed`'s persistent `--linked`/`--local`). */ function ownFlagsOf(command: Command.Command.Any): ReadonlyArray { const internals = commandInternals(command); - return singleParamsOf([ + return unwrappedParamsOf([ ...internals.config.flags, ...internals.contextConfig.flags, ...userGlobalFlagParams(command), - ]).map((single) => [ + ]).map(({ single, isOptional }) => [ single.name, { isBoolean: single.primitiveType._tag === "Boolean", + isOptional, choices: choiceKeysOf(single.primitiveType), }, ]); diff --git a/apps/cli/tests/helpers/command-flags.unit.test.ts b/apps/cli/tests/helpers/command-flags.unit.test.ts index 6695cb276c..252b8c9ee2 100644 --- a/apps/cli/tests/helpers/command-flags.unit.test.ts +++ b/apps/cli/tests/helpers/command-flags.unit.test.ts @@ -19,16 +19,23 @@ describe("commandTreeFor", () => { it("describes each flag's accepted values, a leaf's own flag winning over a global of the same name", () => { expect(tree.get("db advisors")?.flagInfo.get("type")).toEqual({ isBoolean: false, + isOptional: true, choices: ["all", "security", "performance"], }); expect(tree.get("db advisors")?.flagInfo.get("linked")?.isBoolean).toBe(true); expect(tree.get("db query")?.flagInfo.get("output")?.choices).toBeDefined(); expect(tree.get("db diff")?.flagInfo.get("output")).toEqual({ isBoolean: false, + isOptional: true, choices: undefined, }); }); + it("tells a defaulted switch from an optional one", () => { + expect(tree.get("link")?.flagInfo.get("skip-pooler")?.isOptional).toBe(false); + expect(tree.get("db query")?.flagInfo.get("linked")?.isOptional).toBe(true); + }); + it("marks a group as a non-leaf and its subcommand as a leaf", () => { expect(tree.get("db")?.isLeaf).toBe(false); expect(tree.get("db query")?.isLeaf).toBe(true); diff --git a/apps/cli/tests/helpers/permission-drift.ts b/apps/cli/tests/helpers/permission-drift.ts index 0537eab6f1..bf63332afc 100644 --- a/apps/cli/tests/helpers/permission-drift.ts +++ b/apps/cli/tests/helpers/permission-drift.ts @@ -1,17 +1,28 @@ -import { applicableOperations, declaredPermissions } from "./permission-registry.ts"; +import { + DB_TARGET_FLAGS, + applicableEntries, + applicableOperations, + declaredPermissions, +} from "./permission-registry.ts"; import type { ActiveFlags, DeclaredPermissionsLookup, PermissionVariant, } from "./permission-registry.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "./permission-global-flags.ts"; -import type { CommandPermissions } from "../../src/command-internal/command-permissions/model.ts"; +import type { + CommandPermissions, + EntryCondition, + OperationEntry, +} from "../../src/command-internal/command-permissions/model.ts"; import { commandTreeFor } from "./command-flags.ts"; import { matchOperation } from "./operation-table.ts"; export interface RecordedMethodUrl { readonly method: string; readonly url: string; + /** Query parameters Effect's HttpClient keeps apart from `url` (a recorded request's `urlParams`). */ + readonly urlParams?: string; } export interface DriftCheckOptions { @@ -39,23 +50,71 @@ export interface AssertPermissionDriftOptions extends DriftCheckOptions { function describeActiveFlags(activeFlags: ActiveFlags): string { const entries = Object.entries(activeFlags).map(([flag, value]) => - value === true ? flag : `${flag}=${value}`, + value === true ? flag : `${flag}=${JSON.stringify(value)}`, ); return `[${entries.join(", ")}]`; } -/** Every flag name `command`'s declaration classifies anywhere — a global, a no-effect flag, or a `when` condition. */ +/** Every flag name `command`'s declaration classifies anywhere — a global, a no-effect flag, a `when` condition, or a database-target flag a condition selects on. */ function knownFlagNames( permissions: Extract, ): Set { const names = new Set(GLOBAL_NO_API_EFFECT_FLAGS); for (const flag of permissions.noApiEffectFlags) names.add(flag); for (const entry of permissions.operations) { - for (const cond of entry.when ?? []) names.add(cond.flag); + for (const cond of entry.when ?? []) { + if (cond.dbTarget === undefined) names.add(cond.flag); + else for (const flag of DB_TARGET_FLAGS) names.add(flag); + } } return names; } +/** The query parameters a recorded request carries, whether in `url` or kept apart in `urlParams`. */ +function queryOf(request: RecordedMethodUrl, apiUrl: string | undefined): URLSearchParams { + const params = new URL(request.url, apiUrl ?? "https://api.supabase.com").searchParams; + for (const [key, value] of new URLSearchParams(request.urlParams ?? "")) { + params.append(key, value); + } + return params; +} + +/** + * A request must carry the qualifiers of at least one applicable entry for its operation, and no + * qualifier value the declaration names for the operation without an applicable entry declaring it. + */ +function assertQualifiersMatch( + command: string, + request: RecordedMethodUrl, + sent: URLSearchParams, + applicable: ReadonlyArray, + declaredForOperation: ReadonlyArray, +): void { + const label = `${request.method} ${new URL(request.url, "https://api.supabase.com").pathname}`; + if ( + !applicable.some((entry) => + Object.entries(entry.request?.query ?? {}).every(([key, value]) => sent.get(key) === value), + ) + ) { + throw new Error( + `assertPermissionDrift: "${command}": request ${label} was sent without the query qualifiers its declaration lists (${applicable + .map((entry) => JSON.stringify(entry.request?.query)) + .join(" or ")}).`, + ); + } + const mentioned = new Set( + declaredForOperation.flatMap((entry) => Object.keys(entry.request?.query ?? {})), + ); + for (const key of mentioned) { + const value = sent.get(key); + if (value !== null && !applicable.some((entry) => entry.request?.query?.[key] === value)) { + throw new Error( + `assertPermissionDrift: "${command}": request ${label} carries ${key}=${value}, which no applicable declaration entry lists for this operation.`, + ); + } + } +} + /** * Fails when a recorded Management API request diverges from `command`'s declared permissions. * Calls a test mocks away entirely (a layer replaced wholesale) never reach `requests`, so they @@ -102,18 +161,32 @@ export function assertDriftAgainst( } const known = knownFlagNames(declared); - const valueChecked = new Set( - declared.operations.flatMap((entry) => - (entry.when ?? []).flatMap((cond) => (cond.oneOf === undefined ? [] : [cond.flag])), - ), - ); + const conditionFlags = (pick: (cond: EntryCondition) => boolean) => + new Set( + declared.operations.flatMap((entry) => + (entry.when ?? []).flatMap((cond) => + cond.flag !== undefined && pick(cond) ? [cond.flag] : [], + ), + ), + ); + const valueChecked = conditionFlags((cond) => cond.oneOf !== undefined); + const switchChecked = conditionFlags((cond) => cond.is !== undefined); + for (const flag of switchChecked) { + // A switch the command lacks (a block's condition) can't be named, and counts as false. + if (options.acceptedFlags !== undefined && !options.acceptedFlags.has(flag)) continue; + if (typeof activeFlags[flag] !== "boolean") { + throw new Error( + `assertPermissionDrift: "${command}": a \`when\` condition checks the value of --${flag}, a switch — pass its effective value as a boolean in activeFlags (a switch defaults to false).`, + ); + } + } for (const [flag, value] of Object.entries(activeFlags)) { if (!known.has(flag)) { throw new Error( `assertPermissionDrift: "${command}": activeFlags names "${flag}", which this command's declaration never classifies — check for a typo.`, ); } - if (value === true && valueChecked.has(flag)) { + if (typeof value !== "string" && valueChecked.has(flag)) { throw new Error( `assertPermissionDrift: "${command}": activeFlags gives "${flag}" no value, but a \`when\` condition checks its value — pass the value this run uses.`, ); @@ -122,6 +195,11 @@ export function assertDriftAgainst( const allDeclaredIds = new Set(declared.operations.map((entry) => entry.operationId)); const activeEntries = applicableOperations(declared, activeFlags); + const applicableByOperation = Map.groupBy( + applicableEntries(declared, activeFlags), + (entry) => entry.operationId, + ); + const declaredByOperation = Map.groupBy(declared.operations, (entry) => entry.operationId); const activeIds = new Set(activeEntries.map((entry) => entry.operationId)); const requestedIds = new Set(); @@ -134,7 +212,16 @@ export function assertDriftAgainst( ); } requestedIds.add(match.operation.operationId); - if (activeIds.has(match.operation.operationId)) continue; + if (activeIds.has(match.operation.operationId)) { + assertQualifiersMatch( + command, + request, + queryOf(request, options.apiUrl), + applicableByOperation.get(match.operation.operationId) ?? [], + declaredByOperation.get(match.operation.operationId) ?? [], + ); + continue; + } if (allDeclaredIds.has(match.operation.operationId)) { throw new Error( `assertPermissionDrift: "${command}": request ${request.method} ${match.operation.pathTemplate} resolved to operation "${match.operation.operationId}", which is declared for this command but whose \`when\` condition doesn't hold for activeFlags=${describeActiveFlags(activeFlags)} — check that activeFlags matches what this test run actually exercises, including the default value of a flag a value condition checks.`, diff --git a/apps/cli/tests/helpers/permission-drift.unit.test.ts b/apps/cli/tests/helpers/permission-drift.unit.test.ts index 722fa8d8b7..d8b6fe8a4c 100644 --- a/apps/cli/tests/helpers/permission-drift.unit.test.ts +++ b/apps/cli/tests/helpers/permission-drift.unit.test.ts @@ -117,6 +117,37 @@ describe("assertDriftAgainst", () => { ).toThrow(/gives "type" no value, but a `when` condition checks its value/); }); + it("rejects a switch condition whose flag is missing from activeFlags or given a non-boolean", () => { + const switchChecked = declared({ + status: "mapped", + operations: [ + { + operationId: "v1-list-all-secrets", + kind: "required", + when: [{ flag: "skip", is: false }], + }, + ], + noApiEffectFlags: [], + }); + const rejected: Array> = [{}, { skip: "false" }]; + for (const activeFlags of rejected) { + expect(() => + assertDriftAgainst(switchChecked, { + command: "secrets list", + activeFlags, + requests: [listSecrets], + }), + ).toThrow(/checks the value of --skip, a switch — pass its effective value as a boolean/); + } + expect(() => + assertDriftAgainst(switchChecked, { + command: "secrets list", + activeFlags: { skip: false }, + requests: [listSecrets], + }), + ).not.toThrow(); + }); + it("rejects an active flag the command does not accept, even if a declaration condition names it", () => { const withBlockCondition = declared({ status: "mapped", @@ -161,6 +192,67 @@ describe("assertDriftAgainst", () => { ).toThrow(/"v1-run-a-query", which is declared for this command but whose `when` condition/); }); + describe("request qualifiers", () => { + const apiKeys = `${API}/v1/projects/${REF}/api-keys`; + const withReveal = declared({ + status: "mapped", + operations: [ + { + operationId: "v1-get-project-api-keys", + kind: "required", + request: { query: { reveal: "true" } }, + }, + ], + noApiEffectFlags: [], + }); + const revealOnlyWithFlag = declared({ + status: "mapped", + operations: [ + { operationId: "v1-get-project-api-keys", kind: "required" }, + { + operationId: "v1-get-project-api-keys", + kind: "required", + when: [{ flag: "reveal" }], + request: { query: { reveal: "true" } }, + }, + ], + noApiEffectFlags: [], + }); + const check = ( + lookup: DeclaredPermissionsLookup, + request: { method: string; url: string; urlParams?: string }, + activeFlags: Record = {}, + ) => assertDriftAgainst(lookup, { command: "keys", activeFlags, requests: [request] }); + + it("accepts a request carrying the declared qualifier in its url or its urlParams", () => { + expect(() => + check(withReveal, { method: "GET", url: `${apiKeys}?reveal=true` }), + ).not.toThrow(); + expect(() => + check(withReveal, { method: "GET", url: apiKeys, urlParams: "reveal=true" }), + ).not.toThrow(); + }); + + it("rejects a request sent without the declared qualifier or with another value", () => { + expect(() => check(withReveal, { method: "GET", url: apiKeys })).toThrow( + /sent without the query qualifiers its declaration lists/, + ); + expect(() => + check(withReveal, { method: "GET", url: apiKeys, urlParams: "reveal=false" }), + ).toThrow(/sent without the query qualifiers/); + }); + + it("rejects a qualifier the declaration lists only under a flag the run does not use", () => { + const plain = { method: "GET", url: apiKeys }; + const revealed = { method: "GET", url: apiKeys, urlParams: "reveal=true" }; + expect(() => check(revealOnlyWithFlag, plain)).not.toThrow(); + expect(() => check(revealOnlyWithFlag, revealed)).toThrow( + /carries reveal=true, which no applicable declaration entry lists/, + ); + expect(() => check(revealOnlyWithFlag, revealed, { reveal: true })).not.toThrow(); + }); + }); + it("resolves requests against a custom API URL", () => { expect(() => assertDriftAgainst(mappedCommand, { diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 17be1528dd..47002f010c 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -4,7 +4,8 @@ import { rootCommandForFeatures } from "../../src/cli/root.ts"; import { readPermissions } from "../../src/command-internal/command-permissions/command-permissions.annotation.ts"; import type { CommandPermissions, - FlagCondition, + DbTarget, + EntryCondition, OperationEntry, } from "../../src/command-internal/command-permissions/model.ts"; import { findCommand } from "../../src/shared/cli/command-docs.ts"; @@ -13,19 +14,45 @@ import { findCommand } from "../../src/shared/cli/command-docs.ts"; export type PermissionVariant = Parameters[0]; /** - * The flags one run uses, keyed by name without the leading `--`: a flag's effective value - * (its default included) where a value condition checks it, otherwise `true`. + * The flags one run uses, keyed by name without the leading `--`: a switch's effective value + * (its default included) where an `is` condition checks it, a string where a `oneOf` condition + * checks the value, otherwise `true`. List only the database-target flags (`db-url`, `local`, + * `linked`) the run was given: the CLI treats one as set whenever it appears on the command line. */ -export type ActiveFlags = Readonly>; +export type ActiveFlags = Readonly>; -function conditionHolds(condition: FlagCondition, activeFlags: ActiveFlags): boolean { +/** The flags that select a database target, in precedence order. */ +export const DB_TARGET_FLAGS = [ + "db-url", + "local", + "linked", +] as const satisfies ReadonlyArray; + +/** The target `activeFlags` selects (`db-url`, then `local`, then `linked`), else `fallback`. */ +function dbTargetOf( + activeFlags: ActiveFlags, + fallback: DbTarget | undefined, +): DbTarget | undefined { + for (const flag of DB_TARGET_FLAGS) { + if (Object.hasOwn(activeFlags, flag)) return flag; + } + return fallback; +} + +function conditionHolds( + condition: EntryCondition, + activeFlags: ActiveFlags, + dbTarget: () => DbTarget, +): boolean { + if (condition.dbTarget !== undefined) return condition.dbTarget.includes(dbTarget()); const value = Object.hasOwn(activeFlags, condition.flag) ? activeFlags[condition.flag] : undefined; if (condition.oneOf !== undefined) { return typeof value === "string" && condition.oneOf.includes(value); } - return (value !== undefined) === (condition.present ?? true); + if (condition.is !== undefined) return (value ?? false) === condition.is; + return (value !== undefined && value !== "") === (condition.present ?? true); } /** Higher wins a dedupe conflict: a required check with no `context` must never lose to one that has one. */ @@ -50,16 +77,33 @@ function dedupeOperations( type MappedCommandPermissions = Extract; -/** The entries of `permissions` that apply under `activeFlags`, deduped by `operationId`. */ -export function applicableOperations( +/** The entries of `permissions` that apply under `activeFlags`, one per declaration (not deduped). */ +export function applicableEntries( permissions: MappedCommandPermissions, activeFlags: ActiveFlags, ): ReadonlyArray { - const applicable = permissions.operations.filter( + const dbTarget = (): DbTarget => { + const target = dbTargetOf(activeFlags, permissions.dbTargetDefault); + if (target === undefined) { + throw new Error( + "an entry checks the database target, but activeFlags names none of --db-url, --local, --linked and the declaration sets no default (withDbTarget)", + ); + } + return target; + }; + return permissions.operations.filter( (entry) => - entry.when === undefined || entry.when.every((cond) => conditionHolds(cond, activeFlags)), + entry.when === undefined || + entry.when.every((cond) => conditionHolds(cond, activeFlags, dbTarget)), ); - return dedupeOperations(applicable); +} + +/** The entries of `permissions` that apply under `activeFlags`, deduped by `operationId`. */ +export function applicableOperations( + permissions: MappedCommandPermissions, + activeFlags: ActiveFlags, +): ReadonlyArray { + return dedupeOperations(applicableEntries(permissions, activeFlags)); } export type DeclaredPermissionsLookup = diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index a1662bcaf3..e5b7e5c967 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -50,6 +50,76 @@ describe("applicableOperations", () => { expect(applied({})).toEqual([]); }); + it("applies a switch condition to the switch's effective value", () => { + const permissions = mapped([ + { + operationId: "v1-get-pooler-config", + kind: "best-effort", + when: [{ flag: "skip-pooler", is: false }], + }, + ]); + const applied = (activeFlags: Parameters[1]) => + applicableOperations(permissions, activeFlags).map((e) => e.operationId); + expect(applied({ "skip-pooler": false })).toEqual(["v1-get-pooler-config"]); + expect(applied({ "skip-pooler": true })).toEqual([]); + expect(applied({})).toEqual(["v1-get-pooler-config"]); + }); + + it("treats a flag given an empty value as absent", () => { + const permissions = mapped([ + { + operationId: "v1-create-login-role", + kind: "required", + when: [{ flag: "password", present: false }], + }, + { operationId: "v1-list-all-projects", kind: "required", when: [{ flag: "project-ref" }] }, + ]); + const applied = (activeFlags: Parameters[1]) => + applicableOperations(permissions, activeFlags).map((e) => e.operationId); + expect(applied({ password: "" })).toEqual(["v1-create-login-role"]); + expect(applied({ password: "hunter2" })).toEqual([]); + expect(applied({ password: "x", "project-ref": "" })).toEqual([]); + expect(applied({ password: "x", "project-ref": "abcdefghijklmnopqrst" })).toEqual([ + "v1-list-all-projects", + ]); + }); + + describe("database target", () => { + const entries = [ + { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ dbTarget: ["local", "db-url"] }], + }, + ] as const; + const applied = ( + activeFlags: Parameters[1], + dbTargetDefault?: "local" | "linked", + ) => + applicableOperations( + { ...mapped(entries), ...(dbTargetDefault === undefined ? {} : { dbTargetDefault }) }, + activeFlags, + ).map((e) => e.operationId); + + it("takes --db-url over --local over --linked", () => { + expect(applied({ linked: true, local: true, "db-url": true })).toEqual([ + "v1-get-pooler-config", + ]); + expect(applied({ linked: true, local: true })).toEqual(["v1-get-pooler-config"]); + expect(applied({ linked: true }, "local")).toEqual(["v1-run-a-query"]); + }); + + it("falls back to the declared default when no target flag is given", () => { + expect(applied({}, "linked")).toEqual(["v1-run-a-query"]); + expect(applied({}, "local")).toEqual(["v1-get-pooler-config"]); + }); + + it("rejects a run with no target flag and no default", () => { + expect(() => applied({})).toThrow(/sets no default/); + }); + }); + it("keeps a required entry with no context over a required entry that has one, for the same operationId", () => { const permissions = mapped([ { From d5ac73f665a21d72a6c87b96e8632417a6b0d465 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 12:56:15 +0000 Subject: [PATCH 05/11] ci(release): give sync repair the failing test output, not just the log tail (#7120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The sync repair agent only received the last 150 lines of each failed CI job. Turbo interleaves package output, so on https://github.com/supabase/cli/pull/7112 the failing CLI integration test landed mid-log, behind the `@supabase/stack` output, and the agent reported it could not see the failure. `failureExcerpt` now keeps 40 lines after every `##[error]` annotation and vitest `FAIL` header, plus the existing tail, with ANSI codes and timestamps stripped. Gaps are marked with `[...]`. ## Linked issue - [x] I'm a Supabase maintainer. ## Checklist - [x] The PR title follows Conventional Commits. - [x] Tests added or updated for the change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/scripts/sync-agent.test.ts | 22 ++++++++++++ .github/scripts/sync-agent.ts | 49 ++++++++++++++++++++++++-- .github/sync-branches/repair-prompt.md | 2 +- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/.github/scripts/sync-agent.test.ts b/.github/scripts/sync-agent.test.ts index 8db92aa61c..cb8a51aa18 100644 --- a/.github/scripts/sync-agent.test.ts +++ b/.github/scripts/sync-agent.test.ts @@ -7,6 +7,7 @@ import { type CheckRun, REQUIRED_CHECKS } from "./fast-forward.ts"; import { createTestRepo, git, type TestRepo } from "./promotion-test-repo.ts"; import { buildSuggestions, + failureExcerpt, leftoverConflictMarkers, planRepair, type ReviewThread, @@ -340,3 +341,24 @@ describe("leftoverConflictMarkers", () => { } }); }); + +test("failure excerpt keeps an early test failure that the log tail would drop", () => { + const log = [ + "2026-10-13T10:48:27.3385710Z supabase:test:integration: \x1b[41m FAIL \x1b[49m src/signing-key.integration.test.ts > flushes telemetry", + "2026-10-13T10:48:27.3386440Z AssertionError: expected 'supabase/.env' to be '.env'", + ...Array.from( + { length: 1000 }, + (_, index) => `2026-10-13T10:49:30.0000000Z stack output ${index}`, + ), + "2026-10-13T10:49:31.0000000Z ##[error]Process completed with exit code 1.", + ].join("\n"); + + const excerpt = failureExcerpt(log); + + expect(excerpt).toContain("AssertionError: expected 'supabase/.env' to be '.env'"); + expect(excerpt).toContain("##[error]Process completed with exit code 1."); + expect(excerpt).not.toContain("stack output 100\n"); + expect(excerpt).toContain("[...]"); + expect(excerpt).not.toContain("\x1b"); + expect(excerpt).not.toMatch(/^\S+Z /m); +}); diff --git a/.github/scripts/sync-agent.ts b/.github/scripts/sync-agent.ts index 77fe12a382..b5427aa171 100644 --- a/.github/scripts/sync-agent.ts +++ b/.github/scripts/sync-agent.ts @@ -584,7 +584,11 @@ async function fetchOpenFindings( }); } -async function logTail(token: string, repository: string, jobId: number): Promise { +async function jobFailureExcerpt( + token: string, + repository: string, + jobId: number, +): Promise { const response = await fetch( `https://api.github.com/repos/${repository}/actions/jobs/${jobId}/logs`, { @@ -594,7 +598,46 @@ async function logTail(token: string, repository: string, jobId: number): Promis if (!response.ok) { return `(log unavailable: ${response.status})`; } - return (await response.text()).split("\n").slice(-LOG_TAIL_LINES).join("\n"); + return failureExcerpt(await response.text()); +} + +const ERROR_CONTEXT_LINES = 40; +const MAX_ERROR_WINDOWS = 10; + +/** + * Keeps the lines after each error annotation plus the log tail, because Turbo interleaves packages and the failing + * one's output often ends long before the job does. + */ +export function failureExcerpt(log: string): string { + const lines = log + // oxlint-disable-next-line no-control-regex -- strips ANSI colour codes from job logs + .replace(/\x1b\[[0-9;]*m/g, "") + .split("\n") + .map((line) => line.replace(/^\S+Z /, "")); + const kept = new Set(); + let windows = 0; + lines.forEach((line, index) => { + // Turbo prefixes each line with its task name when it streams several packages. + if (windows < MAX_ERROR_WINDOWS && /^(\S+:\s)?\s*(##\[error\]|FAIL\s)/.test(line)) { + windows += 1; + for (let at = index; at < Math.min(index + ERROR_CONTEXT_LINES, lines.length); at += 1) { + kept.add(at); + } + } + }); + for (let at = Math.max(0, lines.length - LOG_TAIL_LINES); at < lines.length; at += 1) { + kept.add(at); + } + let previous = -1; + const excerpt: string[] = []; + for (const index of [...kept].sort((a, b) => a - b)) { + if (index !== previous + 1) { + excerpt.push("[...]"); + } + excerpt.push(lines[index] ?? ""); + previous = index; + } + return excerpt.join("\n"); } /** Whether an `AI Review` run for this pull request is queued or running; its run name carries the PR number. */ @@ -688,7 +731,7 @@ async function collect(outputDir: string): Promise { const failures = await Promise.all( plan.failures.map( async (run) => - `## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await logTail(token, repository, run.id)}\n\`\`\`\`\n`, + `## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await jobFailureExcerpt(token, repository, run.id)}\n\`\`\`\`\n`, ), ); writeFileSync(join(contextDir, "ci-failures.md"), failures.join("\n") || "No CI job failed.\n"); diff --git a/.github/sync-branches/repair-prompt.md b/.github/sync-branches/repair-prompt.md index 33b9622e3e..9c78ecd6e9 100644 --- a/.github/sync-branches/repair-prompt.md +++ b/.github/sync-branches/repair-prompt.md @@ -8,7 +8,7 @@ major; `next` collects breaking changes for the following major. The working dir sync branch: `develop` with `next` merged in and its conflicts already resolved, but some checks fail. Repository conventions live in `CLAUDE.md`, `AGENTS.md`, and `docs/adr/`. Read these first: -- `ci-failures.md` in the added context directory: the failed checks and the end of their output. +- `ci-failures.md` in the added context directory: the failed checks, the output after each error, and the end of their logs. - `review-findings.md` in the same directory: the AI review's open findings, numbered. ## Your task From c7d2ba83520535bc5e8beafb00dd5c50677fc2a3 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 14:03:23 +0000 Subject: [PATCH 06/11] ci(release): land approved sync heads even when the source moved (#7125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Approving a sync PR required its branch to contain the latest source tip (`develop` for `sync/develop-into-next`). A sync PR stays open while `develop` keeps moving, so any new conflict sent it back to a human, as on https://github.com/supabase/cli/pull/7112#issuecomment-6146430127. - When only the source moved, the fast-forward job now lands the approved head that CI tested. - When the target moved, it merges only the latest target into the approved head. A conflict there still asks for re-approval. - Newer source commits arrive with the next `Sync branches` run, on the next push to the source. The release-process doc is updated to match. ### Before ```mermaid flowchart LR A[sync PR approved] --> T{next moved?} T -- no --> S{develop moved?} S -- no --> F[fast-forward next] S -- yes --> M T -- yes --> M{merge latest next + develop cleanly?} M -- yes --> L[land merged head] M -- no --> H[human resolves conflict, re-approves] ``` ### After ```mermaid flowchart LR A[sync PR approved] --> T{next moved?} T -- no --> F[fast-forward next to approved head] T -- yes --> M{merge latest next cleanly?} M -- yes --> L[land merged head] M -- no --> H[human resolves conflict, re-approves] F --> D[next develop push: Sync branches brings the rest] L --> D ``` ## Linked issue - [x] I'm a Supabase maintainer. ## Checklist - [x] The PR title follows Conventional Commits. - [x] Tests added or updated for the change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/MAINTAINERS.md | 7 +-- .github/scripts/fast-forward.test.ts | 49 ++++--------------- .github/scripts/fast-forward.ts | 71 ++++++++++------------------ apps/cli/docs/release-process.md | 2 +- 4 files changed, 39 insertions(+), 90 deletions(-) diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index 6487cbfd0f..c0eba167bb 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -141,9 +141,10 @@ lists, and approve ([runbook](../apps/cli/docs/release-process.md#claude-resolved-develop-into-next)). Otherwise merge the target into the sync branch, resolve, and push; then **approve** the PR. Approval fast-forwards the -target and deletes the branch. Never use the merge button. If the target or the -source moved, the bot merges the latest target and source into the approved head and lands -it directly if the merge is clean, as for a clean sync (the merge commit is +target and deletes the branch. Never use the merge button. A moved source does +not block landing; the next sync run brings its newer commits in. If the target +moved, the bot merges the latest target into the approved head and lands it +directly if the merge is clean, as for a clean sync (the merge commit is untested); only a new conflict, or a target that keeps moving, sends the PR back for a new approval. Only two sync pairs exist (`sync/main-into-develop` into `develop`, `sync/develop-into-next` into diff --git a/.github/scripts/fast-forward.test.ts b/.github/scripts/fast-forward.test.ts index acebc80566..008058b89e 100644 --- a/.github/scripts/fast-forward.test.ts +++ b/.github/scripts/fast-forward.test.ts @@ -733,8 +733,11 @@ describe("runFastForward sync", () => { expect(() => repo.remoteTip("sync/main-into-develop")).toThrow(); }); - test("lands the approved head merged with a non-conflicting target move", async () => { + test("lands the approved head merged with a target move without pulling in the moved source", async () => { const { repo, head, io, comments } = resolvedSyncScenario(); + git(repo.seed, "switch", "main"); + const movedMain = repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main"); + git(repo.seed, "push", "origin", "main"); git(repo.seed, "switch", "develop"); const movedDevelop = repo.commit(repo.seed, "late.txt", "late\n", "feat(cli): late"); git(repo.seed, "push", "origin", "develop"); @@ -745,6 +748,7 @@ describe("runFastForward sync", () => { expect(outcome).toEqual({ status: "merged-after-resync", sha: landed }); expect(repo.isAncestor(head, landed)).toBe(true); expect(repo.isAncestor(movedDevelop, landed)).toBe(true); + expect(repo.isAncestor(movedMain, landed)).toBe(false); expect(() => repo.remoteTip("sync/main-into-develop")).toThrow(); expect(comments).toHaveLength(1); expect(comments[0]).toContain("moved since approval"); @@ -846,52 +850,17 @@ describe("runFastForward sync", () => { expect(comments[0]).toContain("- `shared.txt`"); }); - test("lands the approved head merged with a non-conflicting source move", async () => { - const { repo, head, io, comments } = resolvedSyncScenario(); - git(repo.seed, "switch", "main"); - const movedMain = repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main"); - git(repo.seed, "push", "origin", "main"); - - const outcome = await runFastForward(io, { reviewCommitId: head }); - - const landed = repo.remoteTip("develop"); - expect(outcome).toEqual({ status: "merged-after-resync", sha: landed }); - expect(landed).not.toBe(head); - expect(repo.isAncestor(head, landed)).toBe(true); - expect(repo.isAncestor(movedMain, landed)).toBe(true); - expect(() => repo.remoteTip("sync/main-into-develop")).toThrow(); - expect(comments).toHaveLength(1); - expect(comments[0]).toContain("`main` moved since approval"); - }); - - test("refuses and leaves the branch alone when a moved source conflicts", async () => { + test("fast-forwards the approved head even when the source moved with a conflicting change", async () => { const { repo, head, io, comments } = resolvedSyncScenario(); - const developBefore = repo.remoteTip("develop"); git(repo.seed, "switch", "main"); repo.commit(repo.seed, "shared.txt", "conflicting main\n", "fix(cli): conflicting main"); git(repo.seed, "push", "origin", "main"); const outcome = await runFastForward(io, { reviewCommitId: head }); - expect(outcome.status).toBe("refused"); - expect(repo.remoteTip("sync/main-into-develop")).toBe(head); - expect(repo.remoteTip("develop")).toBe(developBefore); - expect(comments[0]).toContain("`main` moved since approval"); - expect(comments[0]).toContain("- `shared.txt`"); - }); - - test("dry run pushes nothing when the source moved cleanly", async () => { - const { repo, head, io, comments } = resolvedSyncScenario(); - const developBefore = repo.remoteTip("develop"); - git(repo.seed, "switch", "main"); - repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main"); - git(repo.seed, "push", "origin", "main"); - - const outcome = await runFastForward(io, { reviewCommitId: head, dryRun: true }); - - expect(outcome.status).toBe("dry-run"); - expect(repo.remoteTip("develop")).toBe(developBefore); - expect(repo.remoteTip("sync/main-into-develop")).toBe(head); + expect(outcome).toEqual({ status: "fast-forwarded", sha: head }); + expect(repo.remoteTip("develop")).toBe(head); + expect(() => repo.remoteTip("sync/main-into-develop")).toThrow(); expect(comments).toEqual([]); }); }); diff --git a/.github/scripts/fast-forward.ts b/.github/scripts/fast-forward.ts index c9159fe396..77b9ff82cd 100644 --- a/.github/scripts/fast-forward.ts +++ b/.github/scripts/fast-forward.ts @@ -371,40 +371,31 @@ async function resyncSyncBranch( promotion: Promotion, headSha: string, syncBranch: string, - moved: string, ): Promise { const { git } = io; - const { target, source } = promotion; + const { target } = promotion; const targetRef = `refs/remotes/origin/${target}`; let resyncedSha = headSha; for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) { - gitOrThrow(git, [ - "fetch", - "--no-tags", - "origin", - `+refs/heads/${target}:${targetRef}`, - `+refs/heads/${source}:refs/remotes/origin/${source}`, - ]); + gitOrThrow(git, ["fetch", "--no-tags", "origin", `+refs/heads/${target}:${targetRef}`]); const targetSha = gitOrThrow(git, ["rev-parse", targetRef]); gitOrThrow(git, ["checkout", "--detach", headSha]); - for (const ref of [target, source]) { - const merge = git(["merge", "--no-edit", `refs/remotes/origin/${ref}`]); - if (merge.status !== 0) { - const files = git(["diff", "--name-only", "--diff-filter=U"]).stdout; - git(["merge", "--abort"]); - const list = files - .split("\n") - .filter(Boolean) - .map((file) => `- \`${file}\``) - .join("\n"); - return refuse( - io, - input, - `Merging ${ref} into ${syncBranch} conflicts`, - `\`${moved}\` moved since approval and merging \`${ref}\` into \`${syncBranch}\` conflicts:\n\n${list}\n\nResolve on the branch and re-approve.`, - ); - } + const merge = git(["merge", "--no-edit", targetRef]); + if (merge.status !== 0) { + const files = git(["diff", "--name-only", "--diff-filter=U"]).stdout; + git(["merge", "--abort"]); + const list = files + .split("\n") + .filter(Boolean) + .map((file) => `- \`${file}\``) + .join("\n"); + return refuse( + io, + input, + `Merging ${target} into ${syncBranch} conflicts`, + `\`${target}\` moved since approval and merging it into \`${syncBranch}\` conflicts:\n\n${list}\n\nResolve on the branch and re-approve.`, + ); } resyncedSha = gitOrThrow(git, ["rev-parse", "HEAD"]); @@ -416,7 +407,7 @@ async function resyncSyncBranch( if (pushMergedTarget(git, target, targetSha) === "pushed") { git(["push", "origin", "--delete", syncBranch]); await io.comment( - `\`${moved}\` moved since approval, so I merged the latest \`${target}\` and \`${source}\` into the approved head and landed it as ${short(resyncedSha)}.`, + `\`${target}\` moved since approval, so I merged the latest \`${target}\` into the approved head and landed it as ${short(resyncedSha)}.`, ); return { status: "merged-after-resync", sha: resyncedSha }; } @@ -424,7 +415,7 @@ async function resyncSyncBranch( gitOrThrow(git, ["push", "origin", `${resyncedSha}:refs/heads/${syncBranch}`]); await io.comment( - `\`${target}\` kept moving, so I merged the latest \`${target}\` and \`${source}\` into \`${syncBranch}\` (${short(resyncedSha)}). Re-approve once checks pass.`, + `\`${target}\` kept moving, so I merged the latest \`${target}\` into \`${syncBranch}\` (${short(resyncedSha)}). Re-approve once checks pass.`, ); return { status: "resynced", sha: resyncedSha }; } @@ -482,9 +473,6 @@ export async function runFastForward( const fetchRefs = [ `+refs/heads/${promotion.target}:refs/remotes/origin/${promotion.target}`, `+refs/pull/${pullRequest.number}/head:refs/ff/pr-head`, - ...(promotion.kind === "sync" - ? [`+refs/heads/${promotion.source}:refs/remotes/origin/${promotion.source}`] - : []), ]; gitOrThrow(git, ["fetch", "--no-tags", "origin", ...fetchRefs]); @@ -515,18 +503,9 @@ export async function runFastForward( const canFastForward = (): boolean => git(["merge-base", "--is-ancestor", targetRef, headSha]).status === 0; - const sourceCurrent = (): boolean => - promotion.kind !== "sync" || - git(["merge-base", "--is-ancestor", `refs/remotes/origin/${promotion.source}`, headSha]) - .status === 0; - - let movedBranch: string | null = null; - if (!canFastForward()) { - movedBranch = promotion.target; - } else if (!sourceCurrent()) { - movedBranch = promotion.source; - } - if (movedBranch === null) { + // Source commits newer than the approved head reach the target through the next sync run. + let targetMoved = !canFastForward(); + if (!targetMoved) { const push = pushSha(io, input, headSha, promotion.target); if (!push.ok) { gitOrThrow(git, [ @@ -538,13 +517,13 @@ export async function runFastForward( if (canFastForward()) { throw new Error(`git push to ${promotion.target} failed: ${push.stderr}`); } - movedBranch = promotion.target; + targetMoved = true; } } - if (movedBranch !== null) { + if (targetMoved) { if (promotion.kind === "sync") { - return resyncSyncBranch(io, input, promotion, headSha, pullRequest.head.ref, movedBranch); + return resyncSyncBranch(io, input, promotion, headSha, pullRequest.head.ref); } const recovery = promotion.kind === "deploy" diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 753cef6099..d089c98724 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -378,7 +378,7 @@ Before pushing, the job checks, in order: 2. **Approved commit is the head.** The approval's commit must equal the current PR head, and the PR must be open and not a draft. A stale approval is rejected with a comment; re-approve the new head. 3. **Required checks are green on the head.** `Check code quality`, `Run unit and integration tests`, `Run end-to-end tests`, and `Lint Pull Request` must all have succeeded. They are read from the PR's own check rollup (GraphQL), counting only GitHub Actions runs attached to this PR, so runs of another PR on the same commit cannot satisfy or mask a check. Deploy PRs and hotfix PRs into `main` run the full suite (`test.yml` also triggers on PRs into `main`), so these checks exist on the deploy PR head. 4. **Major guard (any promotion into `main`).** If the commits since the last stable tag include a breaking `!` title, the PR needs the `release-major` label. Without it the job comments and fails. -5. **Target has not moved.** The push is a plain, non-force push of the approved commit, so the server refuses anything that is not a fast-forward. For deploy and cut PRs the job comments and fails; update the PR and re-approve. When the deploy PR is refused because `main` has commits `develop` lacks, the comment names the open `sync/main-into-develop` pull request, or the sync dispatch if none is open. A sync PR also counts as stale when the current source tip (`main` or `develop`) is not in the approved head, even if the target did not move. For sync PRs the bot merges the latest target and source into the approved head and, when that merge is clean, pushes it straight to the target and comments, as for a clean sync (the merge commit is untested). A new conflict, or a target that keeps moving, sends the PR back for re-approval instead: on a conflict the bot comments and fails; if the target keeps moving it pushes the re-synced head to the sync branch and comments, and you re-approve once checks pass. +5. **Target has not moved.** The push is a plain, non-force push of the approved commit, so the server refuses anything that is not a fast-forward. For deploy and cut PRs the job comments and fails; update the PR and re-approve. When the deploy PR is refused because `main` has commits `develop` lacks, the comment names the open `sync/main-into-develop` pull request, or the sync dispatch if none is open. A sync PR lands its approved head even when its source (`main` or `develop`) has moved on; the next push to the source runs `Sync branches` again and brings the newer commits in. When the target moved, the bot merges the latest target into the approved head and, when that merge is clean, pushes it straight to the target and comments, as for a clean sync (the merge commit is untested). A new conflict, or a target that keeps moving, sends the PR back for re-approval instead: on a conflict the bot comments and fails; if the target keeps moving it pushes the re-synced head to the sync branch and comments, and you re-approve once checks pass. When all checks pass and no major is involved, a deploy PR behaves as before: the App pushes the approved commit to `main` and `push: main` triggers the stable release. From e58c2837a215f13c9ba36529d551ca8f2d666f91 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 14:03:58 +0000 Subject: [PATCH 07/11] ci(stack): run macOS native scenarios on a larger runner (#7126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS native-scenarios job is the longest job in the Test workflow, about twice any other. Most of it is `native: parallel stack isolation`, which starts two full native stacks at once and is starved on the standard `macos-latest` runner: it takes roughly 2x longer there than on the 8 vCPU Linux native job running the same test. Sharding the scenario files did not help (#7121), since Vitest already ran both files concurrently. This moves the job to the 6 vCPU Blacksmith macOS runner (`blacksmith-6vcpu-macos-latest`) so the two stacks get enough CPU and memory, keeping full macOS coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 981705f990..1888b65913 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -212,7 +212,7 @@ jobs: matrix: # Native artifacts exist for macOS and Linux only, and macOS runners have # no Docker; Linux coverage for both backends comes from test-stack-e2e. - os: [macos-latest] + os: [blacksmith-6vcpu-macos-latest] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 4038b133a1603cd2aba7256d7aac111024c5d2c1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 13 Oct 2026 14:32:45 +0000 Subject: [PATCH 08/11] feat(cli): warn at runtime on deprecated db pull and domains flags (CLI-2599) (#7115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stable now warns on stderr for every flag that CLI-2596 removes in v3. Each warning names the replacement and says the flag goes away in v3. | Flag | Warning | | -- | -- | | `domains create\|get\|reverify\|activate\|delete --include-raw-output` | `Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.` (new, previously help text only) | | `db pull --use-pg-delta` | `Flag --use-pg-delta is deprecated and will be removed in v3, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.` (reworded) | | `db pull --experimental` (structured dump) | ``The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3. Use --declarative instead to pull the remote schema as per-object files.`` (reworded) | - The domains warning fires only when the flag is set. It goes to stderr, so `-o json` / stream-json stdout is unchanged. - There are no other behaviour changes. `--include-raw-output` still forces JSON output, and it is still inert on `delete`. - Flag-level usage over the last 30 days is low: `--include-raw-output` 2 runs from 1 person, `--use-pg-delta` 47/30, `--experimental` 17/9. The numbers are on CLI-2596. The removal itself is in a separate PR against `next`. ## Linked issue Closes CLI-2599 ## Checklist - [x] The PR title follows Conventional Commits. - [x] Tests added or updated: each domains command's integration test covers the warning with and without the flag, and the db pull tests assert the new wording. - [x] `pnpm check:all` passes. The apps/cli unit and integration suites pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- apps/cli/src/command-internal/db-pull-run.ts | 4 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 8 ++-- .../commands/db/pull/pull.integration.test.ts | 14 ++++--- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 3 +- .../domains/activate/activate.command.ts | 2 +- .../domains/activate/activate.handler.ts | 10 ++++- .../activate/activate.integration.test.ts | 40 +++++++++++++++--- .../commands/domains/create/create.command.ts | 2 +- .../commands/domains/create/create.handler.ts | 10 ++++- .../domains/create/create.integration.test.ts | 42 ++++++++++++++++--- .../commands/domains/delete/delete.command.ts | 2 +- .../commands/domains/delete/delete.handler.ts | 8 ++-- .../domains/delete/delete.integration.test.ts | 26 ++++++++++-- .../commands/domains/domains.deprecation.ts | 16 +++++++ .../src/commands/domains/get/get.command.ts | 2 +- .../src/commands/domains/get/get.handler.ts | 10 ++++- .../domains/get/get.integration.test.ts | 42 ++++++++++++++++--- .../domains/reverify/reverify.command.ts | 2 +- .../domains/reverify/reverify.handler.ts | 10 ++++- .../reverify/reverify.integration.test.ts | 40 +++++++++++++++--- 20 files changed, 239 insertions(+), 54 deletions(-) create mode 100644 apps/cli/src/commands/domains/domains.deprecation.ts diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index d01b9d1c54..1f92838e7c 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -90,7 +90,7 @@ export type { DbPullFlags }; // Established output contract; ends with a `.`. const DEPRECATION_LINE = - "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; + "Flag --use-pg-delta is deprecated and will be removed in v3, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; /** * Explains the in-sync non-zero exit instead of the generic "Try rerunning the command with @@ -107,7 +107,7 @@ const MIGRATION_FILE_MODE = 0o644; // export covers the same per-object-files outcome. Printed only when the experimental gate // selected this branch, not when `--declarative` already did. const EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE = - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release. Use --declarative instead to pull the remote schema as per-object files."; + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3. Use --declarative instead to pull the remote schema as per-object files."; export type DbPullInvoke = { /** Skip printing `Finished supabase db pull.` for callers that must not emit it. */ diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index dd5da5ad77..81db74a60e 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -163,7 +163,7 @@ Progress to stderr. Migration path: `Creating shadow database...`, `Diffing schemas[: ]`, `Schema written to `. Declarative path: `Preparing declarative schema export using pg-delta...`, `Declarative schema written to `. Plus the `--use-pg-delta` deprecation line, the -`--experimental` structured-dump deprecation line, and the history-update +`--experimental` structured-dump deprecation line (both name v3), and the history-update prompt. On success the PostRun line `Finished supabase db pull.` is printed to stdout. @@ -190,7 +190,7 @@ Progress strings still go to stderr; stdout carries a single structured envelope env var simply leaves unused on a non-linked target). The deprecated `--experimental` export honors `--project-ref` the same way `--declarative` does. -- `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. +- `--use-pg-delta` is hidden and emits `Flag --use-pg-delta is deprecated and will be removed in v3, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.` to stderr. - Migration-style pulls always compare migrations with the live target; declarative files and `schema_paths` do not replace that baseline. - Bundled nontransactional files begin with @@ -201,8 +201,8 @@ Progress strings still go to stderr; stdout carries a single structured envelope empty diff is "No schema changes found". Stack-backed pulls never take this path. - The `--experimental` structured-dump branch (or the `SUPABASE_EXPERIMENTAL` project-`.env` equivalent) now runs the same in-process declarative export - as `--declarative`. It is deprecated: a warning pointing at `--declarative` - prints to stderr before the export. `--experimental --declarative` does not + as `--declarative`. It is deprecated and will be removed in v3: a warning pointing + at `--declarative` prints to stderr before the export. `--experimental --declarative` does not print that line. Output is the pg-delta declarative tree under `supabase/schemas` (plus `.pgdelta-export.json`), not Go's former `schemas/` + `cluster/` AST split. diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index a626a31c54..b0be6b3e14 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -1040,7 +1040,9 @@ describe("db pull", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* dbPull(flags({ usePgDelta: Option.some(true) })); - expect(streamText(s.out, "stderr")).toContain("Flag --use-pg-delta has been deprecated"); + expect(streamText(s.out, "stderr")).toContain( + "Flag --use-pg-delta is deprecated and will be removed in v3, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.\n", + ); const path = yield* Path.Path; expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${path.join("supabase", "schemas")}\n`, @@ -1763,7 +1765,7 @@ describe("db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", ); expect(streamText(s.out, "stderr")).toContain("Preparing declarative schema export"); }).pipe(Effect.provide(s.layer)); @@ -1818,7 +1820,7 @@ describe("db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); expect(s.proxyCalls).toHaveLength(0); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", ); }).pipe(Effect.provide(s.layer)); }); @@ -1842,7 +1844,7 @@ describe("db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", ); }).pipe(Effect.provide(s.layer)); }, @@ -2412,7 +2414,9 @@ describe("db remote commit", () => { expect(err).toContain( `Command "commit" is deprecated and will be removed in v3, use "db pull" instead.\n`, ); - expect(err).toContain("The --experimental structured-dump mode for `db pull` is deprecated"); + expect(err).toContain( + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", + ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; expect( diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index ed8baa4a33..9c2e7404f9 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -93,8 +93,9 @@ suppressed on stderr. `delete` ignores `-o`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. - All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`. +- `--include-raw-output` is deprecated: passing it with any value (including `--include-raw-output=false`) prints `Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.` to stderr before the API call, in every output mode, on all five subcommands (including `delete`). stdout is unaffected. - **Documented divergences from the old Go CLI (intentional):** - - `--include-raw-output` is declared as a normal boolean **on each subcommand** (the old Go CLI declared it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce the old help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert. + - `--include-raw-output` is declared as an optional boolean **on each subcommand** (the old Go CLI declared it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce the old help-hiding. It still reproduces the behavioral effect (forces `-o json` when the value is `true` and `-o` is unset/pretty); on `delete` it is inert. - The degenerate `validation_records != 1` status message approximates a Go-style `%+v` struct dump (which embeds a non-deterministic pointer address). - Text-mode status output is newline-terminated even where the old CLI's output wasn't. Without the final newline, interactive shell prompts can redraw over the last status line, hiding the ACME TXT record. - In a structured `-o` mode the human status is suppressed on stderr, keeping stdout clean and matching the parity contract. diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index e4cb5d7b8a..9a83478686 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -13,7 +13,7 @@ const config = { ), includeRawOutput: Flag.Boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), - Flag.withDefault(false), + Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index cc267ad126..e3844d3436 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -1,10 +1,11 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { warnIncludeRawOutputDeprecated } from "../domains.deprecation.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; @@ -21,6 +22,8 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + yield* warnIncludeRawOutputDeprecated(flags.includeRawOutput); + const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { @@ -32,6 +35,9 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( ); yield* activating?.clear ?? Effect.void; - yield* emitHostnameResult(response, flags.includeRawOutput); + yield* emitHostnameResult( + response, + Option.getOrElse(flags.includeRawOutput, () => false), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index eabbff260a..f352793246 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -65,7 +65,9 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; +const baseFlags = { projectRef: Option.none(), includeRawOutput: Option.none() }; + +const DeprecationJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); describe("domains activate integration", () => { it.effect("suggests upgrade from entitlement_required envelope on 400", () => { @@ -127,11 +129,39 @@ describe("domains activate integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("forces Go JSON output when --include-raw-output is set", () => { + it.effect( + "warns on stderr and keeps stdout JSON parseable when --include-raw-output is set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsActivate({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ); + const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); + expect(parsed.custom_hostname).toBe("shop.acme.dev"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("warns but does not force JSON when --include-raw-output=false", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* domainsActivate({ projectRef: Option.none(), includeRawOutput: true }); - expect(out.stdoutText.startsWith("{")).toBe(true); + yield* domainsActivate({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); + expect( + out.stderrText.startsWith( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not warn when --include-raw-output is not set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsActivate(baseFlags); + expect(out.stderrText).not.toContain("deprecated"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index 008fde822d..f8f7737ed6 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -16,7 +16,7 @@ const config = { ), includeRawOutput: Flag.Boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), - Flag.withDefault(false), + Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 96d91eca98..76336a82aa 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; @@ -8,6 +8,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { verifyCname } from "../domains.cname.ts"; +import { warnIncludeRawOutputDeprecated } from "../domains.deprecation.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; @@ -24,6 +25,8 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + yield* warnIncludeRawOutputDeprecated(flags.includeRawOutput); + const ref = yield* resolver.resolve(flags.projectRef); // Write the linked-project cache and persist the telemetry state file on @@ -48,6 +51,9 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai ); yield* creating?.clear ?? Effect.void; - yield* emitHostnameResult(response, flags.includeRawOutput); + yield* emitHostnameResult( + response, + Option.getOrElse(flags.includeRawOutput, () => false), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 07571e7642..6ccb0b2d23 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema } from "effect"; import { TestClock } from "effect/testing"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -98,11 +98,11 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -function flags(over: Partial<{ includeRawOutput: boolean }> = {}) { +function flags(over: Partial<{ includeRawOutput: Option.Option }> = {}) { return { projectRef: Option.none(), customHostname: CUSTOM_HOSTNAME, - includeRawOutput: over.includeRawOutput ?? false, + includeRawOutput: over.includeRawOutput ?? Option.none(), }; } @@ -110,6 +110,8 @@ function postedToInitialize(api: { requests: ReadonlyArray<{ url: string }> }): return api.requests.some((r) => r.url.includes("/custom-hostname/initialize")); } +const DeprecationJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); + describe("domains create integration", () => { it.effect("verifies the CNAME, creates the hostname, and prints status to stderr", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); @@ -262,11 +264,39 @@ describe("domains create integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("forces Go JSON output when --include-raw-output is set", () => { + it.effect( + "warns on stderr and keeps stdout JSON parseable when --include-raw-output is set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsCreate(flags({ includeRawOutput: Option.some(true) })); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ); + const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); + expect(parsed.custom_hostname).toBe(CUSTOM_HOSTNAME); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("warns but does not force JSON when --include-raw-output=false", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* domainsCreate(flags({ includeRawOutput: true })); - expect(out.stdoutText.startsWith("{")).toBe(true); + yield* domainsCreate(flags({ includeRawOutput: Option.some(false) })); + expect( + out.stderrText.startsWith( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not warn when --include-raw-output is not set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsCreate(flags()); + expect(out.stderrText).not.toContain("deprecated"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index 0206785052..c45cecd071 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -13,7 +13,7 @@ const config = { ), includeRawOutput: Flag.Boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), - Flag.withDefault(false), + Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index 0cab96bb18..c8286c3017 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -5,6 +5,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { warnIncludeRawOutputDeprecated } from "../domains.deprecation.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import type { DomainsDeleteFlags } from "./delete.command.ts"; @@ -12,9 +13,8 @@ const mapDeleteError = mapDomainsHttpError("delete"); const DELETE_SUCCESS_MESSAGE = "Deleted custom hostname config successfully."; -// `flags.includeRawOutput` is unread: `--include-raw-output` is a persistent flag on the -// `domains` group, so it's accepted on `delete` too, but ignored — delete has no response -// body to encode. Asserted by the "ignores --include-raw-output" integration test. +// `--include-raw-output` is accepted on `delete` but inert (only the deprecation warning fires): +// delete has no response body to encode. export const domainsDelete = Effect.fn("domains.delete")(function* (flags: DomainsDeleteFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; @@ -22,6 +22,8 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + yield* warnIncludeRawOutputDeprecated(flags.includeRawOutput); + const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index e874de9286..f7e62c7c80 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -43,7 +43,7 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; +const baseFlags = { projectRef: Option.none(), includeRawOutput: Option.none() }; describe("domains delete integration", () => { it.effect("prints the success line to stderr in text mode", () => { @@ -84,12 +84,30 @@ describe("domains delete integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("ignores --include-raw-output (inert on delete, Go parity)", () => { + it.effect( + "is inert apart from the stderr deprecation warning when --include-raw-output is set", + () => { + const { layer, out } = setup({ goOutput: "json" }); + return Effect.gen(function* () { + yield* domainsDelete({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n" + + "Deleted custom hostname config successfully.\n", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("warns when --include-raw-output=false", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* domainsDelete({ projectRef: Option.none(), includeRawOutput: true }); + yield* domainsDelete({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n" + + "Deleted custom hostname config successfully.\n", + ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/domains.deprecation.ts b/apps/cli/src/commands/domains/domains.deprecation.ts new file mode 100644 index 0000000000..324e208706 --- /dev/null +++ b/apps/cli/src/commands/domains/domains.deprecation.ts @@ -0,0 +1,16 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; + +const INCLUDE_RAW_OUTPUT_DEPRECATION = + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n"; + +export const warnIncludeRawOutputDeprecated = Effect.fnUntraced(function* ( + includeRawOutput: Option.Option, +) { + if (Option.isNone(includeRawOutput)) { + return; + } + const output = yield* Output; + yield* output.raw(INCLUDE_RAW_OUTPUT_DEPRECATION, "stderr"); +}); diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index 03d0170ef3..c16551ad4f 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -13,7 +13,7 @@ const config = { ), includeRawOutput: Flag.Boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), - Flag.withDefault(false), + Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index e1c1faa711..2d752e8098 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -1,10 +1,11 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { warnIncludeRawOutputDeprecated } from "../domains.deprecation.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; @@ -19,6 +20,8 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + yield* warnIncludeRawOutputDeprecated(flags.includeRawOutput); + const ref = yield* resolver.resolve(flags.projectRef); // Write the linked-project cache and persist the telemetry state file on @@ -34,6 +37,9 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF ); yield* fetching?.clear ?? Effect.void; - yield* emitHostnameResult(response, flags.includeRawOutput); + yield* emitHostnameResult( + response, + Option.getOrElse(flags.includeRawOutput, () => false), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index df325190ba..86d38e6acd 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -78,7 +78,9 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; +const baseFlags = { projectRef: Option.none(), includeRawOutput: Option.none() }; + +const DeprecationJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); describe("domains get integration", () => { it.effect("prints the hostname status to stderr in text mode", () => { @@ -336,12 +338,40 @@ describe("domains get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("forces Go JSON output when --include-raw-output is set", () => { + it.effect( + "warns on stderr and keeps stdout JSON parseable when --include-raw-output is set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsGet({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ); + const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); + expect(parsed.custom_hostname).toBe("shop.acme.dev"); + expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("warns but does not force JSON when --include-raw-output=false", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* domainsGet({ projectRef: Option.none(), includeRawOutput: true }); - expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); + yield* domainsGet({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); + expect( + out.stderrText.startsWith( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not warn when --include-raw-output is not set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsGet(baseFlags); + expect(out.stderrText).not.toContain("deprecated"); }).pipe(Effect.provide(layer)); }); @@ -350,7 +380,7 @@ describe("domains get integration", () => { () => { const { layer, out } = setup({ goOutput: "pretty" }); return Effect.gen(function* () { - yield* domainsGet({ projectRef: Option.none(), includeRawOutput: true }); + yield* domainsGet({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); expect(out.stdoutText.startsWith("{")).toBe(true); }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index 0565b0126f..8f686eaa02 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -13,7 +13,7 @@ const config = { ), includeRawOutput: Flag.Boolean("include-raw-output").pipe( Flag.withDescription("(Deprecated) use -o json instead."), - Flag.withDefault(false), + Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index 6b2e47c58d..a7b10b9dee 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -1,10 +1,11 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { warnIncludeRawOutputDeprecated } from "../domains.deprecation.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; @@ -21,6 +22,8 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; + yield* warnIncludeRawOutputDeprecated(flags.includeRawOutput); + const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { @@ -32,6 +35,9 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( ); yield* reverifying?.clear ?? Effect.void; - yield* emitHostnameResult(response, flags.includeRawOutput); + yield* emitHostnameResult( + response, + Option.getOrElse(flags.includeRawOutput, () => false), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index ad3965fb74..d5e24c50b0 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -65,7 +65,9 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; +const baseFlags = { projectRef: Option.none(), includeRawOutput: Option.none() }; + +const DeprecationJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); describe("domains reverify integration", () => { it.effect("suggests upgrade from entitlement_required envelope on 400", () => { @@ -126,11 +128,39 @@ describe("domains reverify integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("forces Go JSON output when --include-raw-output is set", () => { + it.effect( + "warns on stderr and keeps stdout JSON parseable when --include-raw-output is set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsReverify({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); + expect(out.stderrText).toBe( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ); + const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); + expect(parsed.custom_hostname).toBe("shop.acme.dev"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("warns but does not force JSON when --include-raw-output=false", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* domainsReverify({ projectRef: Option.none(), includeRawOutput: true }); - expect(out.stdoutText.startsWith("{")).toBe(true); + yield* domainsReverify({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); + expect( + out.stderrText.startsWith( + "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not warn when --include-raw-output is not set", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* domainsReverify(baseFlags); + expect(out.stderrText).not.toContain("deprecated"); }).pipe(Effect.provide(layer)); }); From 6baa653ab4b924dd30f846ec7905e04bce2648a0 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 13 Oct 2026 14:46:07 +0000 Subject: [PATCH 09/11] chore(cli): map secrets list, snippets list and db query permissions (#7106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Scoped personal access tokens let a user pick exactly which permissions a token gets, so the CLI needs to say which permissions each command requires. This maps the first commands onto the permission model; see [CLI-2474](https://linear.app/supabase/issue/CLI-2474/proposal-derive-and-document-the-management-api-scopes-each-cli). ## What's in this PR - Permission declarations for `secrets list`, `snippets list` and `db query`, built from the shared building blocks (project ref picker, linked-project cache, DB config). `db query` selects its Management API call by database target, defaulting to local. These three work as the worked example for mapping the remaining commands. - Each command's integration test checks the requests it records against its declaration, so the mapping can't drift from the code. The tests also run the commands through the real linked-project cache layer, and `secrets list` through the project picker, so a block dropped from a declaration fails a test. - `assertBlockDrift`, a drift check for building blocks that takes its label from the block instead of a made-up command name. The existing block tests use it, and `permission-drift.ts` has callers now, so its `knip` entry is gone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../branch-ref.resolver.integration.test.ts | 11 +- .../branch-target.integration.test.ts | 11 +- .../db-config.integration.test.ts | 89 ++------- .../get-api-keys.integration.test.ts | 7 +- .../link-services-core.integration.test.ts | 13 +- .../login-api.layer.integration.test.ts | 8 +- .../storage-credentials.integration.test.ts | 7 +- .../upgrade-suggest.unit.test.ts | 11 +- .../src/commands/db/query/query.command.ts | 23 +++ .../db/query/query.integration.test.ts | 187 +++++++++++++++++- .../src/commands/secrets/list/list.command.ts | 11 ++ .../secrets/list/list.integration.test.ts | 94 ++++++++- .../commands/snippets/list/list.command.ts | 11 ++ .../snippets/list/list.integration.test.ts | 51 ++++- .../project-ref.layer.integration.test.ts | 11 +- .../services/services.shared.unit.test.ts | 11 +- .../linked-project-cache.integration.test.ts | 8 +- apps/cli/tests/helpers/permission-drift.ts | 61 ++++-- .../helpers/permission-drift.unit.test.ts | 31 ++- .../helpers/permission-pending-commands.ts | 3 - .../tests/helpers/permission-real-layers.ts | 143 ++++++++++++++ knip.jsonc | 2 - 22 files changed, 633 insertions(+), 171 deletions(-) create mode 100644 apps/cli/tests/helpers/permission-real-layers.ts diff --git a/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts b/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts index ad7c498d97..c26a69149c 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts @@ -3,11 +3,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { jsonResponse, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; -import { mapped } from "./command-permissions/model.ts"; - -const branchRefDeclared = { _tag: "Declared", permissions: mapped(branchRef) } as const; const PARENT_REF = "abcdefghijklmnopqrst"; const BRANCH_REF = "cccccccccccccccccccc"; @@ -70,8 +67,7 @@ describe("resolveBranchProjectRef integration", () => { expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ `GET https://api.supabase.com/v1/branches/${BRANCH_UUID}`, ]); - assertDriftAgainst(branchRefDeclared, { - command: "branch-ref block", + assertBlockDrift(branchRef, { requests: api.requests, }); }).pipe(Effect.provide(api.layer)); @@ -85,8 +81,7 @@ describe("resolveBranchProjectRef integration", () => { expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${PARENT_REF}/branches/feat-x`, ]); - assertDriftAgainst(branchRefDeclared, { - command: "branch-ref block", + assertBlockDrift(branchRef, { requests: api.requests, }); }).pipe(Effect.provide(api.layer)); diff --git a/apps/cli/src/command-internal/branch-target.integration.test.ts b/apps/cli/src/command-internal/branch-target.integration.test.ts index ce7b45cecd..8c8b4e535d 100644 --- a/apps/cli/src/command-internal/branch-target.integration.test.ts +++ b/apps/cli/src/command-internal/branch-target.integration.test.ts @@ -4,11 +4,8 @@ import { Effect, Layer } from "effect"; import { jsonResponse, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { branchTarget, findBranchName } from "./branch-target.ts"; -import { mapped } from "./command-permissions/model.ts"; - -const branchTargetDeclared = { _tag: "Declared", permissions: mapped(branchTarget) } as const; const PARENT_REF = "abcdefghijklmnopqrst"; const BRANCH_REF = "cccccccccccccccccccc"; @@ -46,8 +43,7 @@ describe("findBranchName integration", () => { expect(api.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${PARENT_REF}/branches`, ]); - assertDriftAgainst(branchTargetDeclared, { - command: "branch-target block", + assertBlockDrift(branchTarget, { requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -59,8 +55,7 @@ describe("findBranchName integration", () => { const name = yield* findBranchName(PARENT_REF, BRANCH_REF); expect(api.requests).toHaveLength(1); expect(name).toBeUndefined(); - assertDriftAgainst(branchTargetDeclared, { - command: "branch-target block", + assertBlockDrift(branchTarget, { requests: api.requests, }); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 4180a26ba1..c66f2c093e 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -44,11 +44,14 @@ import { } from "../../tests/helpers/mocks.ts"; import { VALID_TOKEN, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { unusedGateway } from "../../tests/helpers/unused-stack.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { + primaryPoolerConfigs, + stubManagementApi, +} from "../../tests/helpers/permission-real-layers.ts"; import { applicableOperations } from "../../tests/helpers/permission-registry.ts"; import type { ActiveFlags } from "../../tests/helpers/permission-registry.ts"; import { dbConfig, dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; -import { mapped } from "./command-permissions/model.ts"; import { DebugFlag, DnsResolverFlag, @@ -190,17 +193,13 @@ const assertDbConfigDrift = ( requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, activeFlags: ActiveFlags, ) => - assertDriftAgainst( - { _tag: "Declared", permissions: mapped(dbConfig) }, - { - command: "db-config block", - activeFlags, - requests: requests.map((r) => ({ - method: r.method, - url: `https://api.supabase.com${r.path}`, - })), - }, - ); + assertBlockDrift(dbConfig, { + activeFlags, + requests: requests.map((r) => ({ + method: r.method, + url: `https://api.supabase.com${r.path}`, + })), + }); describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () => @@ -1556,52 +1555,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }); /** Replaces `globalThis.fetch` for the scope: `respond` answers a request, anything else gets a 404. */ -const stubApi = ( - requests: Array<{ readonly method: string; readonly path: string }>, - respond: (method: string, path: string) => Response | undefined, -) => - Effect.acquireRelease( - Effect.sync(() => { - const previousFetch = globalThis.fetch; - globalThis.fetch = Object.assign( - (input: string | URL | Request, init?: RequestInit): Promise => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - return Promise.resolve( - respond(method, url.pathname) ?? - Response.json({ message: "unexpected request" }, { status: 404 }), - ); - }, - { preconnect: previousFetch.preconnect }, - ); - return previousFetch; - }), - (previousFetch) => - Effect.sync(() => { - globalThis.fetch = previousFetch; - }), - ); - -const primaryPooler = (ref: string) => [ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, -]; - describe("dbConfigResolver (Management API calls)", () => { const noDirectConnect = Layer.succeed(DbConnection, { connect: () => Effect.die("unexpected connect()"), @@ -1623,7 +1576,7 @@ describe("dbConfigResolver (Management API calls)", () => { it.effect("--local makes no Management API request and the block declares none", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; return Effect.gen(function* () { - yield* stubApi(requests, () => undefined); + yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); const r = yield* resolve(dir, localFlags); expect(r.isLocal).toBe(true); @@ -1634,7 +1587,7 @@ describe("dbConfigResolver (Management API calls)", () => { it.effect("--db-url makes no Management API request and the block declares none", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; return Effect.gen(function* () { - yield* stubApi(requests, () => undefined); + yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); const r = yield* resolve(dir, dbUrlFlags("postgres://alice:secret@example.com:6543/appdb")); expect(r.isLocal).toBe(false); @@ -1648,7 +1601,7 @@ describe("dbConfigResolver (Management API calls)", () => { const ref = "targetprojectrefabcd"; const requests: Array<{ readonly method: string; readonly path: string }> = []; return Effect.gen(function* () { - yield* stubApi(requests, (method, path) => + yield* stubManagementApi(requests, (method, path) => method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` ? Response.json({ message: "Forbidden" }, { status: 403 }) : undefined, @@ -1690,9 +1643,9 @@ describe("dbConfigResolver (Management API calls)", () => { const ref = "emptyprojectrefabcde"; const requests: Array<{ readonly method: string; readonly path: string }> = []; return Effect.gen(function* () { - yield* stubApi(requests, (method, path) => + yield* stubManagementApi(requests, (method, path) => method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` - ? Response.json(primaryPooler(ref)) + ? Response.json(primaryPoolerConfigs(ref)) : undefined, ); const dir = yield* withWorkdir(); @@ -1732,9 +1685,9 @@ describe("dbConfigResolver (Management API calls)", () => { queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), }; return Effect.gen(function* () { - yield* stubApi(requests, (method, path) => { + yield* stubManagementApi(requests, (method, path) => { if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { - return Response.json(primaryPooler(ref)); + return Response.json(primaryPoolerConfigs(ref)); } if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { return Response.json( @@ -1794,9 +1747,9 @@ describe("dbConfigResolver (Management API calls)", () => { }); const forbiddenResponse = () => Response.json({ message: "Forbidden" }, { status: 403 }); return Effect.gen(function* () { - yield* stubApi(requests, (method, path) => { + yield* stubManagementApi(requests, (method, path) => { if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { - return Response.json(primaryPooler(ref)); + return Response.json(primaryPoolerConfigs(ref)); } if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { return Response.json( diff --git a/apps/cli/src/command-internal/get-api-keys.integration.test.ts b/apps/cli/src/command-internal/get-api-keys.integration.test.ts index 4e0aa5d2d4..44ed691260 100644 --- a/apps/cli/src/command-internal/get-api-keys.integration.test.ts +++ b/apps/cli/src/command-internal/get-api-keys.integration.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { VALID_REF, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { getProjectApiKeys, projectApiKeys } from "./get-api-keys.ts"; -import { mapped } from "./command-permissions/model.ts"; -const declared = { _tag: "Declared", permissions: mapped(projectApiKeys) } as const; const apiKeys = [{ name: "anon", api_key: "anon-key", type: "legacy" }]; const apiKeysUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/api-keys`; @@ -26,8 +24,7 @@ describe("getProjectApiKeys", () => { expect(plain.requests.map((request) => request.urlParams)).toEqual([""]); for (const { requests } of [revealed, plain]) { expect(requests.map((request) => request.url)).toEqual([apiKeysUrl]); - assertDriftAgainst(declared, { - command: "project-api-keys block", + assertBlockDrift(projectApiKeys, { activeFlags: {}, requests, exact: true, diff --git a/apps/cli/src/command-internal/link-services-core.integration.test.ts b/apps/cli/src/command-internal/link-services-core.integration.test.ts index 34079e1b8a..b59827264f 100644 --- a/apps/cli/src/command-internal/link-services-core.integration.test.ts +++ b/apps/cli/src/command-internal/link-services-core.integration.test.ts @@ -10,11 +10,9 @@ import { mockCommandSettings, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { linkServices, linkServicesCore } from "./link-services-core.ts"; -import { mapped } from "./command-permissions/model.ts"; -const declared = { _tag: "Declared", permissions: mapped(linkServices) } as const; const storageConfigUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/config/storage`; const poolerConfigUrl = `https://api.supabase.com/v1/projects/${VALID_REF}/config/database/pooler`; @@ -91,8 +89,7 @@ describe("linkServicesCore", () => { const urls = requests.map((request) => request.url); expect(urls).toContain(storageConfigUrl); expect(urls).toContain(poolerConfigUrl); - assertDriftAgainst(declared, { - command: "link-services block", + assertBlockDrift(linkServices, { activeFlags: { "skip-pooler": false }, requests, }); @@ -120,8 +117,7 @@ describe("linkServicesCore", () => { expect(yield* fs.readFileString(`${temp}/pooler-url`)).toBe( `postgres://postgres.${VALID_REF}@aws-0-us-east-1.pooler.supabase.com:5432/postgres`, ); - assertDriftAgainst(declared, { - command: "link-services block", + assertBlockDrift(linkServices, { activeFlags: { "skip-pooler": false }, requests, }); @@ -153,8 +149,7 @@ describe("linkServicesCore", () => { const urls = requests.map((request) => request.url); expect(urls).toContain(storageConfigUrl); expect(urls).not.toContain(poolerConfigUrl); - assertDriftAgainst(declared, { - command: "link-services block", + assertBlockDrift(linkServices, { activeFlags: { "skip-pooler": true }, requests, }); diff --git a/apps/cli/src/command-internal/login-api.layer.integration.test.ts b/apps/cli/src/command-internal/login-api.layer.integration.test.ts index cbb2857f58..c2d27e9cd2 100644 --- a/apps/cli/src/command-internal/login-api.layer.integration.test.ts +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -8,11 +8,8 @@ import { mockCommandPlatformApi, mockCommandSettings, } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; -import { mapped } from "./command-permissions/model.ts"; - -const declared = { _tag: "Declared", permissions: mapped(loginProfile) } as const; describe("loginApiLayer", () => { it.effect("fetches the account identity from GET /v1/profile after login", () => { @@ -30,8 +27,7 @@ describe("loginApiLayer", () => { expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/profile", ]); - assertDriftAgainst(declared, { - command: "login-profile block", + assertBlockDrift(loginProfile, { activeFlags: {}, requests: api.requests, }); diff --git a/apps/cli/src/command-internal/storage-credentials.integration.test.ts b/apps/cli/src/command-internal/storage-credentials.integration.test.ts index ddd3633ee7..a7b5d4e044 100644 --- a/apps/cli/src/command-internal/storage-credentials.integration.test.ts +++ b/apps/cli/src/command-internal/storage-credentials.integration.test.ts @@ -8,12 +8,10 @@ import { mockCommandSettings, } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; -import { mapped } from "./command-permissions/model.ts"; import { resolveStorageCredentials, storageCredentials } from "./storage-credentials.ts"; -const declared = { _tag: "Declared", permissions: mapped(storageCredentials) } as const; const apiKeys = [ { name: "anon", api_key: "anon-key", type: "legacy" }, { name: "service_role", api_key: "service-role-key", type: "legacy" }, @@ -45,8 +43,7 @@ describe("resolveStorageCredentials for a remote project", () => { expect(credentials.apiKey).toBe("service-role-key"); expect(api.requests.map((request) => request.urlParams)).toEqual(["reveal=true"]); - assertDriftAgainst(declared, { - command: "storage-credentials block", + assertBlockDrift(storageCredentials, { activeFlags: {}, requests: api.requests, }); diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index 9f38ecb843..d15908cd2a 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -13,17 +13,11 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { suggestUpgrade, upgradeSuggestion } from "./upgrade-suggest.ts"; -import { mapped } from "./command-permissions/model.ts"; const ORG_SLUG = "test-org"; -const upgradeSuggestionDeclared = { - _tag: "Declared", - permissions: mapped(upgradeSuggestion), -} as const; - function projectResponse(slug: string = ORG_SLUG) { return { id: VALID_REF, @@ -245,8 +239,7 @@ describe("suggestUpgrade", () => { `GET https://api.supabase.com/v1/projects/${VALID_REF}`, `GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`, ]); - assertDriftAgainst(upgradeSuggestionDeclared, { - command: "upgrade-suggest block", + assertBlockDrift(upgradeSuggestion, { requests: api.requests, }); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index c3812830ae..9693458f7b 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -1,6 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { QUERY_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -51,6 +59,21 @@ export type DbQueryFlags = CliCommand.Command.Config.Infer; export const dbQueryCommand = Command.make("query", config).pipe( Command.withDescription("Execute a SQL query against the database."), Command.withShortDescription("Execute a SQL query against the database"), + withPermissions( + withDbTarget( + compose( + { + operations: [ + { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, + ], + noApiEffectFlags: ["file"], + }, + dbConfig, + withCondition(linkedProjectCache, { flag: "linked" }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbQuery(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index d41ba08efe..8477e2f0f9 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1,7 +1,9 @@ +import type { NetConnectOpts } from "node:net"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + ConfigProvider, Effect, Exit, FileSystem, @@ -15,12 +17,30 @@ import { import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { vi } from "vitest"; + +// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. +vi.mock("node:net", (importOriginal) => + importOriginal().then((net) => ({ + ...net, + connect: (options: NetConnectOpts) => { + const host = "host" in options ? options.host : undefined; + if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { + return net.connect(options); + } + const socket = new net.Socket(); + queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); + return socket; + }, + })), +); import { VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; @@ -44,6 +64,13 @@ import { type PgConnInput, type QueryResult, } from "../../../command-internal/db-connection.service.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + primaryPoolerConfigs, + realDbConfigResolver, + realLinkedProjectCache, + stubManagementApi, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { RLS_CHECK_SQL } from "./query.advisory.ts"; import type { DbQueryFlags } from "./query.command.ts"; import { dbQuery } from "./query.handler.ts"; @@ -173,12 +200,34 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { }); } -function mockHttpClient(opts: { status?: number; body?: string; networkFail?: boolean }) { +function mockHttpClient(opts: { + status?: number; + body?: string; + networkFail?: boolean; + route?: (request: { + method: string; + url: string; + }) => { status: number; body: string } | undefined; +}) { const requests: Array = []; + const calls: Array<{ method: string; url: string }> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { requests.push(request.url); + calls.push({ method: request.method, url: request.url }); + const routed = opts.route?.({ method: request.method, url: request.url }); + if (routed !== undefined) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(routed.body, { + status: routed.status, + headers: { "content-type": "application/json" }, + }), + ), + ); + } return opts.networkFail === true ? Effect.fail( new HttpClientError.HttpClientError({ @@ -201,6 +250,9 @@ function mockHttpClient(opts: { status?: number; body?: string; networkFail?: bo get requests() { return requests; }, + get calls() { + return calls; + }, }; } @@ -225,6 +277,10 @@ interface SetupOpts { unlinked?: boolean; refReadFails?: boolean; resolveFails?: boolean; + /** Runs the real linked-project cache layer against the temp `workdir`, answering its project lookup. */ + realCache?: boolean; + /** Runs the real `DbConfigResolver` against the temp `workdir`; the test serves its Management API calls. */ + realDbConfig?: boolean; } function setup(opts: SetupOpts = {}) { @@ -236,13 +292,38 @@ function setup(opts: SetupOpts = {}) { status: opts.linkedStatus, body: opts.linkedBody, networkFail: opts.networkFail, + route: (request) => + opts.realCache === true && + request.method === "GET" && + request.url.endsWith(`/v1/projects/${REF}`) + ? { + status: 200, + body: JSON.stringify({ + ref: REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }), + } + : undefined, }); const layer = Layer.mergeAll( out.layer, telemetry.layer, - cache.layer, + opts.realCache === true + ? realLinkedProjectCache({ + httpClient: httpClient.layer, + cliSettings: mockCommandSettings({ workdir: opts.workdir ?? "/work/project" }), + }) + : cache.layer, telemetryOutputFormat.layer, - mockResolver(opts.isLocal, opts.resolveFails), + opts.realDbConfig === true + ? realDbConfigResolver({ + workdir: opts.workdir ?? "/work/project", + dbConnection: mockDbConnection(opts), + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : mockResolver(opts.isLocal, opts.resolveFails), mockDbConnection(opts), mockProjectRef(opts.unlinked, opts.refReadFails), mockStdin({ isTTY: opts.stdinTTY, piped: opts.piped }), @@ -296,6 +377,8 @@ const SELECT_RESULT: QueryResult = { }; describe("db query integration", () => { + const tempRoot = useTempWorkdir("supabase-db-query-int-"); + it.live("runs SQL passed as a positional argument and renders a table for humans", () => { const { layer, out, cache } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { @@ -661,7 +744,7 @@ describe("db query integration", () => { }); it.live("queries the linked project over HTTP and writes the linked-project cache", () => { - const { layer, out, cache } = setup({ + const { layer, out, cache, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); @@ -669,9 +752,99 @@ describe("db query integration", () => { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(out.stdoutText).toContain("│ name │ id │"); expect(cache.cached).toBe(true); + assertPermissionDrift({ + command: "db query", + activeFlags: { linked: true }, + requests: httpClient.calls, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fills the linked-project cache through the real cache layer", () => { + const { layer, httpClient } = setup({ + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + realCache: true, + workdir: tempRoot.current, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF }); + expect(httpClient.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${REF}/database/query`, + `GET https://api.supabase.com/v1/projects/${REF}`, + ]); + assertPermissionDrift({ + command: "db query", + activeFlags: { linked: true }, + requests: httpClient.calls, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.live("mints a login role and queries through the real DB config resolver and cache", () => { + const { layer, httpClient } = setup({ + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + realCache: true, + realDbConfig: true, + workdir: tempRoot.current, + }); + const resolverRequests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(resolverRequests, (method, path) => { + if (method === "GET" && path === `/v1/projects/${REF}/config/database/pooler`) { + return Response.json(primaryPoolerConfigs(REF)); + } + if (method === "POST" && path === `/v1/projects/${REF}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }); + yield* dbQuery( + flags({ + sql: Option.some("select 1"), + linked: Option.some(true), + projectRef: Option.some(REF), + }), + ); + expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${REF}/config/database/pooler`, + `POST /v1/projects/${REF}/cli/login-role`, + ]); + assertPermissionDrift({ + command: "db query", + activeFlags: { linked: true, "project-ref": REF }, + requests: [ + ...resolverRequests.map(({ method, path }) => ({ + method, + url: `https://api.supabase.com${path}`, + })), + ...httpClient.calls, + ], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + { preserveEmptyStrings: true }, + ), + ), + ); + }); + it.live("queries the project given via --project-ref without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache, httpClient } = setup({ @@ -693,6 +866,12 @@ describe("db query integration", () => { ).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); + assertPermissionDrift({ + command: "db query", + activeFlags: { linked: true, "project-ref": FLAG_REF }, + requests: httpClient.calls, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/secrets/list/list.command.ts b/apps/cli/src/commands/secrets/list/list.command.ts index 1c142701a6..26224a044c 100644 --- a/apps/cli/src/commands/secrets/list/list.command.ts +++ b/apps/cli/src/commands/secrets/list/list.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -28,6 +32,13 @@ export const secretsListCommand = Command.make("list", config).pipe( description: "List secrets for a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-secrets", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => secretsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/secrets/list/list.integration.test.ts b/apps/cli/src/commands/secrets/list/list.integration.test.ts index 79045f4853..3bc2ffa624 100644 --- a/apps/cli/src/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/commands/secrets/list/list.integration.test.ts @@ -1,25 +1,47 @@ -import { type V1ListAllSecretsOutput } from "@supabase/api/effect"; +import { type V1ListAllProjectsOutput, type V1ListAllSecretsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { secretsList } from "./list.handler.ts"; type SecretsResponse = typeof V1ListAllSecretsOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SAMPLE_SECRETS: SecretsResponse = [ { name: "FOO", value: "digest-foo" }, { name: "BAR", value: "digest-bar" }, ]; +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -197,6 +219,7 @@ describe("secrets list integration", () => { yield* secretsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/secrets`); + assertPermissionDrift({ command: "secrets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -206,6 +229,71 @@ describe("secrets list integration", () => { return Effect.gen(function* () { yield* secretsList({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + assertPermissionDrift({ + command: "secrets list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker when no project is configured", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [PROJECT]) + : jsonResponse(request, 200, SAMPLE_SECRETS), + ), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + yield* secretsList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/secrets`, + ]); + expect(out.stdoutText).toContain("digest-foo"); + assertPermissionDrift({ command: "secrets list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fills the linked-project cache through the real cache layer", () => { + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, PROJECT) + : jsonResponse(request, 200, SAMPLE_SECRETS), + ), + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const layer = buildTestRuntime({ + out: mockOutput(), + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* secretsList({ projectRef: Option.none() }); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/secrets`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "secrets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/snippets/list/list.command.ts b/apps/cli/src/commands/snippets/list/list.command.ts index 20df3ff225..f19f376d28 100644 --- a/apps/cli/src/commands/snippets/list/list.command.ts +++ b/apps/cli/src/commands/snippets/list/list.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -27,6 +31,13 @@ export const snippetsListCommand = Command.make("list", config).pipe( description: "List snippets for a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-snippets", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => snippetsList(flags).pipe( // No `safeFlags` — `--project-ref` is not on the telemetry-safe list, diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index 25c4b73f80..52d7e2eb48 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -1,11 +1,12 @@ import { type V1ListAllSnippetsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, @@ -13,10 +14,14 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { snippetsList } from "./list.handler.ts"; type SnippetsResponse = typeof V1ListAllSnippetsOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SNIPPET_ID = "00000000-0000-4000-8000-000000000001"; const SNIPPET_BASE = { @@ -278,6 +283,7 @@ describe("snippets list integration", () => { yield* snippetsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.urlWithParams).toContain(`/v1/snippets?project_ref=${VALID_REF}`); + assertPermissionDrift({ command: "snippets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -287,6 +293,49 @@ describe("snippets list integration", () => { return Effect.gen(function* () { yield* snippetsList({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.urlWithParams).toContain(`project_ref=${flagRef}`); + assertPermissionDrift({ + command: "snippets list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fills the linked-project cache through the real cache layer", () => { + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, { + ref: VALID_REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }) + : jsonResponse(request, 200, SINGLE_RESPONSE), + ), + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const layer = buildTestRuntime({ + out: mockOutput(), + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* snippetsList({ projectRef: Option.none() }); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/snippets", + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "snippets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/config/project-ref.layer.integration.test.ts b/apps/cli/src/config/project-ref.layer.integration.test.ts index 823b63e9ba..5fcb71d55c 100644 --- a/apps/cli/src/config/project-ref.layer.integration.test.ts +++ b/apps/cli/src/config/project-ref.layer.integration.test.ts @@ -13,9 +13,8 @@ import { useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { projectRefLayer, projectRefPicker } from "./project-ref.layer.ts"; -import { mapped } from "../command-internal/command-permissions/model.ts"; import { ProjectRefResolver } from "./project-ref.service.ts"; type Projects = typeof V1ListAllProjectsOutput.Type; @@ -37,8 +36,6 @@ const PROJECT: Projects[number] = { }, }; -const declared = { _tag: "Declared", permissions: mapped(projectRefPicker) } as const; - describe("projectRefLayer Management API requests", () => { const workdir = useTempWorkdir("project-ref-api-"); @@ -69,8 +66,7 @@ describe("projectRefLayer Management API requests", () => { expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", ]); - assertDriftAgainst(declared, { - command: "project-ref picker block", + assertBlockDrift(projectRefPicker, { activeFlags: {}, requests: api.requests, }); @@ -86,8 +82,7 @@ describe("projectRefLayer Management API requests", () => { expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", ]); - assertDriftAgainst(declared, { - command: "project-ref picker block", + assertBlockDrift(projectRefPicker, { activeFlags: { "project-ref": "" }, requests: api.requests, }); diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 30b415bd1c..55faeb0ea9 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -3,8 +3,7 @@ import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/http"; import { afterEach, beforeEach, vi } from "vitest"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; -import { assertDriftAgainst } from "../../../tests/helpers/permission-drift.ts"; -import { mapped } from "../../command-internal/command-permissions/model.ts"; +import { assertBlockDrift } from "../../../tests/helpers/permission-drift.ts"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { fetchLinkedServiceVersions, @@ -61,11 +60,6 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; const runLinkedFetch = (input: Parameters[0]) => fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); -const linkedServiceVersionsDeclared = { - _tag: "Declared", - permissions: mapped(linkedServiceVersions), -} as const; - // Tenant probes share the test server's origin, so only Management API paths are recorded. const recordManagementRequests = () => { const requests: Array<{ readonly method: string; readonly url: string }> = []; @@ -84,8 +78,7 @@ const assertLinkedServiceVersionsDrift = ( requests: ReadonlyArray<{ readonly method: string; readonly url: string }>, apiUrl: string, ) => - assertDriftAgainst(linkedServiceVersionsDeclared, { - command: "linked-service-versions block", + assertBlockDrift(linkedServiceVersions, { requests, apiUrl, }); diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index cfc473f55a..52413f1b99 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -13,14 +13,11 @@ import { mockCommandCredentialsLayer, mockCommandPlatformApi, } from "../../tests/helpers/command-mocks.ts"; -import { assertDriftAgainst } from "../../tests/helpers/permission-drift.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { linkedProjectCache, linkedProjectCacheLayer } from "./linked-project-cache.layer.ts"; -import { mapped } from "../command-internal/command-permissions/model.ts"; import { identityStitchLayer } from "../command-internal/identity-stitch.ts"; import { LinkedProjectCache } from "./linked-project-cache.service.ts"; -const declared = { _tag: "Declared", permissions: mapped(linkedProjectCache) } as const; - describe("linkedProjectCacheLayer", () => { it.live( "stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", @@ -78,8 +75,7 @@ describe("linkedProjectCacheLayer", () => { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); expect(api.requests).toHaveLength(1); - assertDriftAgainst(declared, { - command: "linked-project-cache block", + assertBlockDrift(linkedProjectCache, { activeFlags: {}, requests: api.requests, }); diff --git a/apps/cli/tests/helpers/permission-drift.ts b/apps/cli/tests/helpers/permission-drift.ts index bf63332afc..1faac0f4c7 100644 --- a/apps/cli/tests/helpers/permission-drift.ts +++ b/apps/cli/tests/helpers/permission-drift.ts @@ -10,15 +10,17 @@ import type { PermissionVariant, } from "./permission-registry.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "./permission-global-flags.ts"; +import { mapped } from "../../src/command-internal/command-permissions/model.ts"; import type { CommandPermissions, EntryCondition, OperationEntry, + PermissionFragment, } from "../../src/command-internal/command-permissions/model.ts"; import { commandTreeFor } from "./command-flags.ts"; import { matchOperation } from "./operation-table.ts"; -export interface RecordedMethodUrl { +interface RecordedMethodUrl { readonly method: string; readonly url: string; /** Query parameters Effect's HttpClient keeps apart from `url` (a recorded request's `urlParams`). */ @@ -43,6 +45,8 @@ export interface DriftCheckOptions { readonly exact?: boolean; } +export interface BlockDriftOptions extends Omit {} + export interface AssertPermissionDriftOptions extends DriftCheckOptions { /** The command tree to look `command` up in; defaults to the default root (`start`/`status`/`stop` differ by stack backend). */ readonly variant?: PermissionVariant; @@ -84,20 +88,20 @@ function queryOf(request: RecordedMethodUrl, apiUrl: string | undefined): URLSea * qualifier value the declaration names for the operation without an applicable entry declaring it. */ function assertQualifiersMatch( - command: string, + prefix: string, request: RecordedMethodUrl, sent: URLSearchParams, applicable: ReadonlyArray, declaredForOperation: ReadonlyArray, ): void { - const label = `${request.method} ${new URL(request.url, "https://api.supabase.com").pathname}`; + const target = `${request.method} ${new URL(request.url, "https://api.supabase.com").pathname}`; if ( !applicable.some((entry) => Object.entries(entry.request?.query ?? {}).every(([key, value]) => sent.get(key) === value), ) ) { throw new Error( - `assertPermissionDrift: "${command}": request ${label} was sent without the query qualifiers its declaration lists (${applicable + `${prefix}: request ${target} was sent without the query qualifiers its declaration lists (${applicable .map((entry) => JSON.stringify(entry.request?.query)) .join(" or ")}).`, ); @@ -109,7 +113,7 @@ function assertQualifiersMatch( const value = sent.get(key); if (value !== null && !applicable.some((entry) => entry.request?.query?.[key] === value)) { throw new Error( - `assertPermissionDrift: "${command}": request ${label} carries ${key}=${value}, which no applicable declaration entry lists for this operation.`, + `${prefix}: request ${target} carries ${key}=${value}, which no applicable declaration entry lists for this operation.`, ); } } @@ -128,12 +132,37 @@ export function assertPermissionDrift(options: AssertPermissionDriftOptions): vo }); } +/** + * Like {@link assertPermissionDrift}, for a building block built with `block()`: the label comes + * from the block's `source`, so a block test names no command. Use it where the module under test + * is the one that makes the calls. + */ +export function assertBlockDrift(fragment: PermissionFragment, options: BlockDriftOptions): void { + const sources = new Set(fragment.operations.map((entry) => entry.source)); + const [name] = sources; + if (sources.size !== 1 || name === undefined) { + throw new Error( + `assertBlockDrift: expected the result of a single block(...) call, but its entries come from ${sources.size === 0 ? "no source" : `${sources.size} sources`}.`, + ); + } + assertDriftAgainst( + { _tag: "Declared", permissions: mapped(fragment) }, + { ...options, command: name, subject: "building block" }, + ); +} + /** {@link assertPermissionDrift} against an already-resolved lookup, so each failure branch can be exercised without a real command. */ export function assertDriftAgainst( lookup: DeclaredPermissionsLookup, - options: DriftCheckOptions & { readonly variant?: PermissionVariant }, + options: DriftCheckOptions & { + readonly variant?: PermissionVariant; + /** What `command` names in error messages; set by {@link assertBlockDrift}. */ + readonly subject?: "command" | "building block"; + }, ): void { - const { command, requests, exact = false } = options; + const { command, requests, exact = false, subject = "command" } = options; + const label = subject === "command" ? `"${command}"` : `${subject} "${command}"`; + const helper = subject === "command" ? "assertPermissionDrift" : "assertBlockDrift"; const activeFlags = options.activeFlags ?? {}; if (lookup._tag === "NotFound") { throw new Error( @@ -155,7 +184,7 @@ export function assertDriftAgainst( for (const flag of Object.keys(activeFlags)) { if (options.acceptedFlags !== undefined && !options.acceptedFlags.has(flag)) { throw new Error( - `assertPermissionDrift: "${command}": activeFlags names "${flag}", but this command does not accept --${flag} — a shared building block may mention it without the command having it.`, + `${helper}: ${label}: activeFlags names "${flag}", but this ${subject} does not accept --${flag} — a shared building block may mention it without the command having it.`, ); } } @@ -176,19 +205,19 @@ export function assertDriftAgainst( if (options.acceptedFlags !== undefined && !options.acceptedFlags.has(flag)) continue; if (typeof activeFlags[flag] !== "boolean") { throw new Error( - `assertPermissionDrift: "${command}": a \`when\` condition checks the value of --${flag}, a switch — pass its effective value as a boolean in activeFlags (a switch defaults to false).`, + `${helper}: ${label}: a \`when\` condition checks the value of --${flag}, a switch — pass its effective value as a boolean in activeFlags (a switch defaults to false).`, ); } } for (const [flag, value] of Object.entries(activeFlags)) { if (!known.has(flag)) { throw new Error( - `assertPermissionDrift: "${command}": activeFlags names "${flag}", which this command's declaration never classifies — check for a typo.`, + `${helper}: ${label}: activeFlags names "${flag}", which this ${subject}'s declaration never classifies — check for a typo.`, ); } if (typeof value !== "string" && valueChecked.has(flag)) { throw new Error( - `assertPermissionDrift: "${command}": activeFlags gives "${flag}" no value, but a \`when\` condition checks its value — pass the value this run uses.`, + `${helper}: ${label}: activeFlags gives "${flag}" no value, but a \`when\` condition checks its value — pass the value this run uses.`, ); } } @@ -208,13 +237,13 @@ export function assertDriftAgainst( if (match.kind === "out-of-scope") continue; if (match.kind === "unmatched") { throw new Error( - `assertPermissionDrift: "${command}": request ${request.method} ${match.pathname} matched no known Management API operation — check operation-table.ts and the spec.`, + `${helper}: ${label}: request ${request.method} ${match.pathname} matched no known Management API operation — check operation-table.ts and the spec.`, ); } requestedIds.add(match.operation.operationId); if (activeIds.has(match.operation.operationId)) { assertQualifiersMatch( - command, + `${helper}: ${label}`, request, queryOf(request, options.apiUrl), applicableByOperation.get(match.operation.operationId) ?? [], @@ -224,11 +253,11 @@ export function assertDriftAgainst( } if (allDeclaredIds.has(match.operation.operationId)) { throw new Error( - `assertPermissionDrift: "${command}": request ${request.method} ${match.operation.pathTemplate} resolved to operation "${match.operation.operationId}", which is declared for this command but whose \`when\` condition doesn't hold for activeFlags=${describeActiveFlags(activeFlags)} — check that activeFlags matches what this test run actually exercises, including the default value of a flag a value condition checks.`, + `${helper}: ${label}: request ${request.method} ${match.operation.pathTemplate} resolved to operation "${match.operation.operationId}", which is declared for this ${subject} but whose \`when\` condition doesn't hold for activeFlags=${describeActiveFlags(activeFlags)} — check that activeFlags matches what this test run actually exercises, including the default value of a flag a value condition checks.`, ); } throw new Error( - `assertPermissionDrift: "${command}": request ${request.method} ${match.operation.pathTemplate} resolved to operation "${match.operation.operationId}", which is not declared for this command at all (activeFlags=${describeActiveFlags(activeFlags)}).`, + `${helper}: ${label}: request ${request.method} ${match.operation.pathTemplate} resolved to operation "${match.operation.operationId}", which is not declared for this ${subject} at all (activeFlags=${describeActiveFlags(activeFlags)}).`, ); } @@ -242,7 +271,7 @@ export function assertDriftAgainst( ); if (missingRequired.length > 0) { throw new Error( - `assertPermissionDrift: "${command}": exact check expected these required operations to be requested but none were: ${missingRequired.map((entry) => entry.operationId).join(", ")} (activeFlags=${describeActiveFlags(activeFlags)}).`, + `${helper}: ${label}: exact check expected these required operations to be requested but none were: ${missingRequired.map((entry) => entry.operationId).join(", ")} (activeFlags=${describeActiveFlags(activeFlags)}).`, ); } } diff --git a/apps/cli/tests/helpers/permission-drift.unit.test.ts b/apps/cli/tests/helpers/permission-drift.unit.test.ts index d8b6fe8a4c..d8c4f0f750 100644 --- a/apps/cli/tests/helpers/permission-drift.unit.test.ts +++ b/apps/cli/tests/helpers/permission-drift.unit.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import type { CommandPermissions } from "../../src/command-internal/command-permissions/model.ts"; import type { DeclaredPermissionsLookup } from "./permission-registry.ts"; -import { assertDriftAgainst } from "./permission-drift.ts"; +import { block } from "../../src/command-internal/command-permissions/model.ts"; +import { assertBlockDrift, assertDriftAgainst } from "./permission-drift.ts"; import { OPERATIONS } from "./operation-table.ts"; const REF = "abcdefghijklmnopqrst"; @@ -307,3 +308,31 @@ describe("assertDriftAgainst", () => { }); }); }); + +describe("assertBlockDrift", () => { + const demo = block("demo", [ + { operationId: "v1-list-all-secrets", kind: "required" }, + { operationId: "v1-run-a-query", kind: "required", when: [{ flag: "linked" }] }, + ]); + + it("names the building block when a request is to an operation it does not declare", () => { + expect(() => assertBlockDrift(demo, { requests: [getProject] })).toThrow( + /assertBlockDrift: building block "demo": request GET .*"v1-get-project", which is not declared for this building block at all/, + ); + }); + + it("rejects a declared operation whose condition does not hold for the active flags", () => { + expect(() => assertBlockDrift(demo, { activeFlags: {}, requests: [runQuery] })).toThrow( + /assertBlockDrift: building block "demo": .*"v1-run-a-query", which is declared for this building block but whose `when` condition/, + ); + }); + + it("names the building block when a switch the block tests is missing from activeFlags", () => { + const switched = block("switched", [ + { operationId: "v1-list-all-secrets", kind: "required", when: [{ flag: "skip", is: false }] }, + ]); + expect(() => assertBlockDrift(switched, { requests: [listSecrets] })).toThrow( + /assertBlockDrift: building block "switched": a `when` condition checks the value of --skip/, + ); + }); +}); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 8408b25b82..0e595022c1 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -40,7 +40,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "db lint", "db pull", "db push", - "db query", "db remote changes", "db remote commit", "db reset", @@ -143,7 +142,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "pull", - "secrets list", "secrets set", "secrets unset", @@ -152,7 +150,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "services", "snippets download", - "snippets list", "ssl-enforcement get", "ssl-enforcement update", diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts new file mode 100644 index 0000000000..d3716913d9 --- /dev/null +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -0,0 +1,143 @@ +import { BunServices } from "@effect/platform-bun"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; +import type * as HttpClient from "effect/http/HttpClient"; + +import { dbConfigLayer } from "../../src/command-internal/db-config.layer.ts"; +import type { DbConfigResolver } from "../../src/command-internal/db-config.service.ts"; +import type { DbConnection } from "../../src/command-internal/db-connection.service.ts"; +import { DebugLogger } from "../../src/command-internal/debug-logger.service.ts"; +import { + DebugFlag, + DnsResolverFlag, + OutputFlag, + ProfileFlag, + WorkdirFlag, +} from "../../src/command-internal/global-flags.ts"; +import { identityStitchLayer } from "../../src/command-internal/identity-stitch.ts"; +import type { CommandSettings } from "../../src/config/command-settings.service.ts"; +import { linkedProjectCacheLayer } from "../../src/telemetry/linked-project-cache.layer.ts"; +import type { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; +import { mockCommandCredentialsLayer, mockCommandSettings } from "./command-mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, +} from "./mocks.ts"; + +/** + * The real `LinkedProjectCache`, so a command test records the `GET /v1/projects/{ref}` the + * cache fill makes and the command's declaration is checked against it. + */ +export function realLinkedProjectCache(opts: { + readonly httpClient: Layer.Layer; + readonly cliSettings: Layer.Layer; +}): Layer.Layer { + const analytics = mockAnalytics(); + const identityStitch = identityStitchLayer.pipe( + Layer.provide(analytics.layer), + Layer.provide(mockTelemetryRuntime()), + Layer.provide(BunServices.layer), + ); + return linkedProjectCacheLayer.pipe( + Layer.provide(opts.httpClient), + Layer.provide(opts.cliSettings), + Layer.provide(mockCommandCredentialsLayer), + Layer.provide(identityStitch), + Layer.provide(analytics.layer), + Layer.provide(BunServices.layer), + ); +} + +/** + * The real `DbConfigResolver` on the linked path: the direct host (`db..invalid`) is + * unreachable once the test file mocks `node:net` to refuse it, so the resolver falls back to + * the pooler and mints a login role through {@link stubManagementApi}. Provide the same `env` + * to the running effect as a `ConfigProvider`, since the resolver reads it when it resolves. + */ +export function realDbConfigResolver(opts: { + readonly workdir: string; + readonly dbConnection: Layer.Layer; + readonly env: Record; +}): Layer.Layer { + const deps = Layer.mergeAll( + mockCommandSettings({ + workdir: opts.workdir, + projectHost: "invalid", + projectId: Option.none(), + }), + opts.dbConnection, + Layer.succeed(DebugLogger, { debug: () => Effect.void, http: () => Effect.void }), + mockOutput().layer, + mockAnalytics().layer, + mockTelemetryRuntime(), + mockTty(), + mockRuntimeInfo(), + Layer.succeed(ProfileFlag, "supabase"), + Layer.succeed(WorkdirFlag, Option.some(opts.workdir)), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(DebugFlag, false), + Layer.succeed(DnsResolverFlag, "native"), + identityStitchLayer.pipe( + Layer.provide(mockAnalytics().layer), + Layer.provide(mockTelemetryRuntime()), + Layer.provide(BunServices.layer), + ), + BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(opts.env, { preserveEmptyStrings: true })), + ); + return dbConfigLayer.pipe(Layer.provide(deps)); +} + +/** + * Replaces the global `fetch` for the scope, recording each Management API request the resolver's + * own client makes. `respond` returns a `Response` for the routes a test serves; any other + * request gets a 404. + */ +export const stubManagementApi = ( + requests: Array<{ readonly method: string; readonly path: string }>, + respond: (method: string, path: string) => Response | undefined, +) => + Effect.acquireRelease( + Effect.sync(() => { + const previousFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + return Promise.resolve( + respond(method, url.pathname) ?? + Response.json({ message: "unexpected request" }, { status: 404 }), + ); + }, + { preconnect: previousFetch.preconnect }, + ); + return previousFetch; + }), + (previousFetch) => + Effect.sync(() => { + globalThis.fetch = previousFetch; + }), + ); + +/** A PRIMARY transaction pooler config for `ref`, as `GET /v1/projects/{ref}/config/database/pooler` returns it. */ +export const primaryPoolerConfigs = (ref: string) => [ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, +]; diff --git a/knip.jsonc b/knip.jsonc index 6e2ecce38d..b472a39fbf 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -23,8 +23,6 @@ "scripts/analyze-commits-title.js", // tests/smoke-test.ts picks the platform file from a string map and spawns it. "tests/smoke-test-*.ts", - // Not yet called from any command's integration test. - "tests/helpers/permission-drift.ts", ], "ignore": [ // Generated by the gen:feedback-types script. From 7954e73d92a9d6f8f7c86b2daf319ba6a7aa302a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:58:40 +0000 Subject: [PATCH 10/11] chore(repo): fix the develop-into-next merge --- .../command-internal/storage-credentials.integration.test.ts | 5 +++-- apps/cli/tests/helpers/permission-real-layers.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/command-internal/storage-credentials.integration.test.ts b/apps/cli/src/command-internal/storage-credentials.integration.test.ts index a7b5d4e044..4a8448d787 100644 --- a/apps/cli/src/command-internal/storage-credentials.integration.test.ts +++ b/apps/cli/src/command-internal/storage-credentials.integration.test.ts @@ -7,6 +7,7 @@ import { mockCommandPlatformApi, mockCommandSettings, } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { unusedStackServices } from "../../tests/helpers/unused-stack.ts"; @@ -16,18 +17,18 @@ const apiKeys = [ { name: "anon", api_key: "anon-key", type: "legacy" }, { name: "service_role", api_key: "service-role-key", type: "legacy" }, ]; -const config = { api: { enabled: true, port: 54321, tls: { enabled: false } }, auth: {} }; describe("resolveStorageCredentials for a remote project", () => { const resolve = ( api: ReturnType, env: Record = {}, ) => - resolveStorageCredentials({ projectRef: VALID_REF, config }).pipe( + resolveStorageCredentials({ projectRef: VALID_REF }).pipe( Effect.provide( Layer.mergeAll( ConfigProvider.layer(ConfigProvider.fromEnvRecord(env)), api.factoryLayer, + cliConfigValuesTestLayer, mockCommandSettings({ workdir: "/unused" }), mockRuntimeInfo(), unusedStackServices, diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index d3716913d9..e02e1f9000 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -5,7 +5,6 @@ import type * as HttpClient from "effect/http/HttpClient"; import { dbConfigLayer } from "../../src/command-internal/db-config.layer.ts"; import type { DbConfigResolver } from "../../src/command-internal/db-config.service.ts"; import type { DbConnection } from "../../src/command-internal/db-connection.service.ts"; -import { DebugLogger } from "../../src/command-internal/debug-logger.service.ts"; import { DebugFlag, DnsResolverFlag, @@ -15,6 +14,7 @@ import { } from "../../src/command-internal/global-flags.ts"; import { identityStitchLayer } from "../../src/command-internal/identity-stitch.ts"; import type { CommandSettings } from "../../src/config/command-settings.service.ts"; +import { DebugLogger } from "../../src/shared/output/debug-logger.service.ts"; import { linkedProjectCacheLayer } from "../../src/telemetry/linked-project-cache.layer.ts"; import type { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; import { mockCommandCredentialsLayer, mockCommandSettings } from "./command-mocks.ts"; From 66f816a7272f1a2a65cd69c9e8e0aa11c0841b12 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:07:24 +0000 Subject: [PATCH 11/11] chore(repo): repair the develop-into-next sync --- apps/cli/src/command-internal/db-config.layer.ts | 12 ++++++++++-- apps/cli/src/command-internal/db-pull-run.ts | 2 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 2 +- .../src/commands/db/pull/pull.integration.test.ts | 6 +++--- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 2 +- .../domains/activate/activate.integration.test.ts | 4 ++-- .../domains/create/create.integration.test.ts | 4 ++-- .../domains/delete/delete.integration.test.ts | 4 ++-- apps/cli/src/commands/domains/domains.deprecation.ts | 2 +- .../src/commands/domains/get/get.integration.test.ts | 4 ++-- .../domains/reverify/reverify.integration.test.ts | 4 ++-- apps/cli/tests/helpers/permission-real-layers.ts | 3 +++ 12 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f6ece8c759..bc4f88b9a6 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -265,6 +265,14 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { } }; +/** + * An explicitly empty `--password` asks for a temporary login role even when + * `SUPABASE_DB_PASSWORD` or the project `.env` holds one. The flag binding drops empty strings, so + * pass it to {@link resolveLinkedPassword} as the caller's password instead. + */ +const emptyPasswordFlag = (password: Option.Option | undefined): Option.Option => + Option.filter(password ?? Option.none(), (value) => value.length === 0); + const loadFailureToDbConfigError = ( error: E, ) => @@ -682,7 +690,7 @@ export const dbConfigResolverLayer = Layer.effect( cliSettings.projectHost, cliSettings.poolerHost, flags.dnsResolver, - Option.none(), + emptyPasswordFlag(flags.password), { adHocProjectRef: flags.adHocProjectRef ?? false, resolveVaultSecrets, @@ -751,7 +759,7 @@ export const dbConfigResolverLayer = Layer.effect( const password = yield* resolveLinkedPassword( ref, cliSettings.workdir, - Option.none(), + emptyPasswordFlag(flags.password), flags.password !== undefined, ); // Container-fallback: fetch the primary pooler config from the Management API when diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index bdffdf66ba..81aa859af7 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -105,7 +105,7 @@ const MIGRATION_FILE_MODE = 0o644; // export covers the same per-object-files outcome. Printed only when the experimental gate // selected this branch, not when `--declarative` already did. const EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE = - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3. Use --declarative instead to pull the remote schema as per-object files."; + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release. Use --declarative instead to pull the remote schema as per-object files."; export type DbPullInvoke = { /** diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 272e032cad..44d08319ef 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -209,7 +209,7 @@ Progress strings still go to stderr; stdout carries a single structured envelope empty diff is "No schema changes found". Stack-backed pulls never take this path. - The `--experimental` structured-dump branch (or the `SUPABASE_EXPERIMENTAL` project-`.env` equivalent) now runs the same in-process declarative export - as `--declarative`. It is deprecated and will be removed in v3: a warning pointing + as `--declarative`. It is deprecated and will be removed in a future release: a warning pointing at `--declarative` prints to stderr before the export. `--experimental --declarative` does not print that line. Output is the pg-delta declarative tree under `supabase/schemas` (plus `.pgdelta-export.json`). diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index ab4d24bca0..eece71b633 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -1772,7 +1772,7 @@ describe("db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release.", ); expect(streamText(s.out, "stderr")).toContain("Preparing declarative schema export"); }).pipe(Effect.provide(s.layer)); @@ -1827,7 +1827,7 @@ describe("db pull", () => { yield* dbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(false) })); expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release.", ); }).pipe(Effect.provide(s.layer)); }); @@ -1849,7 +1849,7 @@ describe("db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( - "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in v3.", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release.", ); }).pipe(Effect.provide(s.layer)); }, diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index be37104e4d..c92642dd0c 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -93,7 +93,7 @@ suppressed on stderr. `delete` ignores `-o`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. - All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`. -- `--include-raw-output` is deprecated: passing it with any value (including `--include-raw-output=false`) prints `Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.` to stderr before the API call, in every output mode, on all five subcommands (including `delete`). stdout is unaffected. +- `--include-raw-output` is deprecated: passing it with any value (including `--include-raw-output=false`) prints `Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.` to stderr before the API call, in every output mode, on all five subcommands (including `delete`). stdout is unaffected. - **Notes:** - `--include-raw-output` is declared as an optional boolean **on each subcommand**. Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it is not hidden from help. It forces `-o json` when the value is `true` and `-o` is unset/pretty; on `delete` it is inert. - The degenerate `validation_records != 1` status message is an approximate struct dump (the exact text is not stable). diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index b6da08943d..2da978c99a 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -136,7 +136,7 @@ describe("domains activate integration", () => { return Effect.gen(function* () { yield* domainsActivate({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ); const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); expect(parsed.custom_hostname).toBe("shop.acme.dev"); @@ -150,7 +150,7 @@ describe("domains activate integration", () => { yield* domainsActivate({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); expect( out.stderrText.startsWith( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ), ).toBe(true); expect(out.stdoutText).toBe(""); diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 1b0c63ba23..040c71b623 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -271,7 +271,7 @@ describe("domains create integration", () => { return Effect.gen(function* () { yield* domainsCreate(flags({ includeRawOutput: Option.some(true) })); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ); const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); expect(parsed.custom_hostname).toBe(CUSTOM_HOSTNAME); @@ -285,7 +285,7 @@ describe("domains create integration", () => { yield* domainsCreate(flags({ includeRawOutput: Option.some(false) })); expect( out.stderrText.startsWith( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ), ).toBe(true); expect(out.stdoutText).toBe(""); diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 38db6c86ab..fc9b4acff1 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -92,7 +92,7 @@ describe("domains delete integration", () => { yield* domainsDelete({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n" + + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n" + "Deleted custom hostname config successfully.\n", ); }).pipe(Effect.provide(layer)); @@ -105,7 +105,7 @@ describe("domains delete integration", () => { yield* domainsDelete({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n" + + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n" + "Deleted custom hostname config successfully.\n", ); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/domains/domains.deprecation.ts b/apps/cli/src/commands/domains/domains.deprecation.ts index 324e208706..7fe334f1b0 100644 --- a/apps/cli/src/commands/domains/domains.deprecation.ts +++ b/apps/cli/src/commands/domains/domains.deprecation.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect"; import { Output } from "../../shared/output/output.service.ts"; const INCLUDE_RAW_OUTPUT_DEPRECATION = - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n"; + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n"; export const warnIncludeRawOutputDeprecated = Effect.fnUntraced(function* ( includeRawOutput: Option.Option, diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index 91e32eab40..ff1dc305e3 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -343,7 +343,7 @@ describe("domains get integration", () => { return Effect.gen(function* () { yield* domainsGet({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ); const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); expect(parsed.custom_hostname).toBe("shop.acme.dev"); @@ -358,7 +358,7 @@ describe("domains get integration", () => { yield* domainsGet({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); expect( out.stderrText.startsWith( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ), ).toBe(true); expect(out.stdoutText).toBe(""); diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index d3507919b2..3a9222bb6d 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -135,7 +135,7 @@ describe("domains reverify integration", () => { return Effect.gen(function* () { yield* domainsReverify({ projectRef: Option.none(), includeRawOutput: Option.some(true) }); expect(out.stderrText).toBe( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ); const parsed = yield* Schema.decodeEffect(DeprecationJsonObject)(out.stdoutText); expect(parsed.custom_hostname).toBe("shop.acme.dev"); @@ -149,7 +149,7 @@ describe("domains reverify integration", () => { yield* domainsReverify({ projectRef: Option.none(), includeRawOutput: Option.some(false) }); expect( out.stderrText.startsWith( - "Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.\n", + "Flag --include-raw-output is deprecated and will be removed in a future release, use -o json instead.\n", ), ).toBe(true); expect(out.stdoutText).toBe(""); diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index e02e1f9000..72b98ea194 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -18,6 +18,8 @@ import { DebugLogger } from "../../src/shared/output/debug-logger.service.ts"; import { linkedProjectCacheLayer } from "../../src/telemetry/linked-project-cache.layer.ts"; import type { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; import { mockCommandCredentialsLayer, mockCommandSettings } from "./command-mocks.ts"; +import { definedEnv } from "./config-env-pins.ts"; +import { configValuesLayer } from "./config-values-layer.ts"; import { mockAnalytics, mockOutput, @@ -86,6 +88,7 @@ export function realDbConfigResolver(opts: { ), BunServices.layer, ConfigProvider.layer(ConfigProvider.fromEnvRecord(opts.env, { preserveEmptyStrings: true })), + configValuesLayer({ env: definedEnv(opts.env) }), ); return dbConfigLayer.pipe(Layer.provide(deps)); }