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/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/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/.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 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..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: @@ -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/.github/workflows/test.yml b/.github/workflows/test.yml index 61bd5ff3b8..d81d2ada51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -207,7 +207,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 diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 9fa5ca167e..1e5e115a59 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -352,7 +352,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. @@ -376,7 +376,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. @@ -485,6 +485,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, }); 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..c26a69149c --- /dev/null +++ b/apps/cli/src/command-internal/branch-ref.resolver.integration.test.ts @@ -0,0 +1,89 @@ +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 { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; + +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}`, + ]); + assertBlockDrift(branchRef, { + 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`, + ]); + assertBlockDrift(branchRef, { + 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..8c8b4e535d --- /dev/null +++ b/apps/cli/src/command-internal/branch-target.integration.test.ts @@ -0,0 +1,63 @@ +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 { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { branchTarget, findBranchName } from "./branch-target.ts"; + +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`, + ]); + assertBlockDrift(branchTarget, { + 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(); + assertBlockDrift(branchTarget, { + 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 a08142e8a1..bf8156ec7e 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"; @@ -43,6 +46,14 @@ import { VALID_TOKEN, mockCommandSettings } from "../../tests/helpers/command-mo import { pinnedConfigProvider } from "../../tests/helpers/config-env-pins.ts"; import { flagInput, withHermeticShellTier } from "../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../tests/helpers/unused-stack.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 { DebugFlag, DnsResolverFlag, @@ -55,9 +66,9 @@ import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { DebugLogger } from "../shared/output/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"; @@ -90,6 +101,7 @@ function buildResolver( readonly projectHost?: string; readonly poolerHost?: string; readonly dbConnection?: Layer.Layer; + readonly debugLogger?: Layer.Layer; readonly configEnv?: Record; readonly stackApi?: Layer.Layer; readonly flagPassword?: string; @@ -111,7 +123,7 @@ function buildResolver( projectId: Option.none(), }), opts.dbConnection ?? mockDbConnection, - mockDebugLogger, + opts.debugLogger ?? mockDebugLogger, mockOutput().layer, mockAnalytics().layer, mockTelemetryRuntime(), @@ -205,6 +217,18 @@ const linkedFlags: DbConfigFlags = { dnsResolver: "native", }; +const assertDbConfigDrift = ( + requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, + activeFlags: ActiveFlags, +) => + 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", () => Effect.gen(function* () { @@ -1292,6 +1316,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( @@ -1410,6 +1435,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( @@ -1461,3 +1617,252 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, ); }); + +/** Replaces `globalThis.fetch` for the scope: `respond` answers a request, anything else gets a 404. */ +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* stubManagementApi(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* 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); + 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* stubManagementApi(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* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPoolerConfigs(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* stubManagementApi(requests, (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; + }); + 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* stubManagementApi(requests, (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 }, + ); + } + 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 dd7874b6bb..bc4f88b9a6 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -45,6 +45,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); @@ -220,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, ) => @@ -637,7 +690,7 @@ export const dbConfigResolverLayer = Layer.effect( cliSettings.projectHost, cliSettings.poolerHost, flags.dnsResolver, - Option.none(), + emptyPasswordFlag(flags.password), { adHocProjectRef: flags.adHocProjectRef ?? false, resolveVaultSecrets, @@ -706,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/get-api-keys.integration.test.ts b/apps/cli/src/command-internal/get-api-keys.integration.test.ts new file mode 100644 index 0000000000..44ed691260 --- /dev/null +++ b/apps/cli/src/command-internal/get-api-keys.integration.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { VALID_REF, mockCommandPlatformApi } from "../../tests/helpers/command-mocks.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { getProjectApiKeys, projectApiKeys } from "./get-api-keys.ts"; + +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]); + assertBlockDrift(projectApiKeys, { + 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..b59827264f --- /dev/null +++ b/apps/cli/src/command-internal/link-services-core.integration.test.ts @@ -0,0 +1,158 @@ +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 { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { linkServices, linkServicesCore } from "./link-services-core.ts"; + +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); + assertBlockDrift(linkServices, { + 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`, + ); + assertBlockDrift(linkServices, { + 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); + assertBlockDrift(linkServices, { + 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 6776673da7..c8e306e8f1 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..c2d27e9cd2 --- /dev/null +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -0,0 +1,52 @@ +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 { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; + +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", + ]); + assertBlockDrift(loginProfile, { + 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..4a8448d787 --- /dev/null +++ b/apps/cli/src/command-internal/storage-credentials.integration.test.ts @@ -0,0 +1,65 @@ +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 { 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"; +import { resolveStorageCredentials, storageCredentials } from "./storage-credentials.ts"; + +const apiKeys = [ + { name: "anon", api_key: "anon-key", type: "legacy" }, + { name: "service_role", api_key: "service-role-key", type: "legacy" }, +]; + +describe("resolveStorageCredentials for a remote project", () => { + const resolve = ( + api: ReturnType, + env: Record = {}, + ) => + resolveStorageCredentials({ projectRef: VALID_REF }).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env)), + api.factoryLayer, + cliConfigValuesTestLayer, + 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"]); + assertBlockDrift(storageCredentials, { + 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 bd7f5895ad..29fb839fcf 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -23,6 +23,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..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,7 +13,8 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; -import { suggestUpgrade } from "./upgrade-suggest.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { suggestUpgrade, upgradeSuggestion } from "./upgrade-suggest.ts"; const ORG_SLUG = "test-org"; @@ -130,27 +131,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 +220,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 +235,13 @@ 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`, + ]); + assertBlockDrift(upgradeSuggestion, { + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -278,7 +295,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 +303,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/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index ef0ab246d5..44d08319ef 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -159,7 +159,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 (which names v3), and the history-update prompt. On success the PostRun line `Finished supabase db pull.` is printed to stdout. @@ -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: 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 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 92a931a95e..eece71b633 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -1045,7 +1045,9 @@ describe("db pull", () => { const s = setup(tmp.current, { usePgDelta: true, 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 has been deprecated, use --declarative instead.\n", + ); const path = yield* Path.Path; expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${path.join("supabase", "schemas")}\n`, @@ -1770,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", + "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)); @@ -1825,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", + "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release.", ); }).pipe(Effect.provide(s.layer)); }); @@ -1847,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", + "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/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index d2b031556f..98df15ac8f 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/output-formats.ts"; @@ -53,6 +61,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 b34e0a5f7e..26d8bb5f3d 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/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 597288d829..c92642dd0c 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 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 a normal 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 and emits no `Flag --include-raw-output has been deprecated` stderr warning, since Effect CLI has no hook for either. It 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**. 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). - Text-mode status output is newline-terminated. 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. 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 3806a9229b..2da978c99a 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 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 a future release, 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 a future release, 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 694a9b152b..040c71b623 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 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 a future release, 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 a future release, 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 e58cdb5017..fc9b4acff1 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)", () => { + it.effect( + "is inert apart from the stderr deprecation warning when --include-raw-output is set", + () => { + const { layer, out } = setup({ outputFlag: "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 a future release, 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 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 new file mode 100644 index 0000000000..7fe334f1b0 --- /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 a future release, 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 1d37c35a53..ff1dc305e3 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", () => { @@ -334,19 +336,47 @@ describe("domains get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("forces 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 a future release, 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 a future release, 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)); }); it.effect("forces JSON even when -o is explicitly pretty and --include-raw-output is set", () => { const { layer, out } = setup({ outputFlag: "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 a930e8781b..3a9222bb6d 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 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 a future release, 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 a future release, 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)); }); 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 7967a39d2f..4c2edeb4a0 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"; outputFlag?: "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 5da39d4c65..be4d166893 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 = { @@ -275,6 +280,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)); }); @@ -284,6 +290,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 new file mode 100644 index 0000000000..5fcb71d55c --- /dev/null +++ b/apps/cli/src/config/project-ref.layer.integration.test.ts @@ -0,0 +1,124 @@ +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 { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { projectRefLayer, projectRefPicker } from "./project-ref.layer.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", + }, +}; + +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", + ]); + assertBlockDrift(projectRefPicker, { + 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", + ]); + assertBlockDrift(projectRefPicker, { + 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 3088625c2e..0db05959ee 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..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,9 +3,11 @@ import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/http"; import { afterEach, beforeEach, vi } from "vitest"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; +import { assertBlockDrift } from "../../../tests/helpers/permission-drift.ts"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { fetchLinkedServiceVersions, + linkedServiceVersions, listLocalServiceVersions, localServiceImagesFromDockerfile, mergeRemoteServiceVersions, @@ -58,6 +60,29 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; const runLinkedFetch = (input: Parameters[0]) => fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); +// 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, +) => + assertBlockDrift(linkedServiceVersions, { + requests, + apiUrl, + }); + const serve = (options: Parameters[0]) => Effect.acquireRelease( Effect.try(() => Bun.serve(options)), @@ -298,9 +323,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 +384,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 +437,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 +497,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/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index f100fbf59d..46bd328e98 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", @@ -492,6 +498,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/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index 4ab800a450..e8e09e4744 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -13,8 +13,9 @@ import { mockCommandCredentialsLayer, mockCommandPlatformApi, } from "../../tests/helpers/command-mocks.ts"; +import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { linkedProjectCache, linkedProjectCacheLayer } from "./linked-project-cache.layer.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"; describe("linkedProjectCacheLayer", () => { @@ -71,6 +72,11 @@ describe("linkedProjectCacheLayer", () => { return Effect.gen(function* () { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toHaveLength(1); + assertBlockDrift(linkedProjectCache, { + 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"), @@ -126,6 +132,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 }); @@ -171,4 +178,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 d9d63e3d8d..2f0c9d049f 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 "../shared/config/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..1faac0f4c7 100644 --- a/apps/cli/tests/helpers/permission-drift.ts +++ b/apps/cli/tests/helpers/permission-drift.ts @@ -1,17 +1,30 @@ -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 { 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`). */ + readonly urlParams?: string; } export interface DriftCheckOptions { @@ -32,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; @@ -39,23 +54,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( + prefix: string, + request: RecordedMethodUrl, + sent: URLSearchParams, + applicable: ReadonlyArray, + declaredForOperation: ReadonlyArray, +): void { + 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( + `${prefix}: request ${target} 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( + `${prefix}: request ${target} 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 @@ -69,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( @@ -96,32 +184,51 @@ 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.`, ); } } 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( + `${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 (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.`, + `${helper}: ${label}: activeFlags gives "${flag}" no value, but a \`when\` condition checks its value — pass the value this run uses.`, ); } } 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(); @@ -130,18 +237,27 @@ 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)) continue; + if (activeIds.has(match.operation.operationId)) { + assertQualifiersMatch( + `${helper}: ${label}`, + 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.`, + `${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)}).`, ); } @@ -155,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 722fa8d8b7..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"; @@ -117,6 +118,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 +193,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, { @@ -215,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..72b98ea194 --- /dev/null +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -0,0 +1,146 @@ +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 { + 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 { 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, + 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 })), + configValuesLayer({ env: definedEnv(opts.env) }), + ); + 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/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([ { diff --git a/knip.jsonc b/knip.jsonc index 83a07f4ddd..71761b117e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -22,8 +22,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. 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),