diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 37fc048a0e..9304447079 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -8,6 +8,8 @@ body: value: | Thanks for helping improve the Supabase CLI. Please include the exact command and output whenever possible so maintainers can reproduce the issue quickly. + Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: dropdown id: affected-area attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8c49825527..00644d304d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: 📖 Contribution workflow + url: https://github.com/supabase/cli/blob/develop/CONTRIBUTING.md + about: Read this before opening an issue or pull request — PRs must link a labeled, open issue. - name: Supabase support url: https://supabase.com/support about: Get help with your Supabase project, account, billing, or hosted services. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 57501fd9d3..aa04c9c2d3 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -3,6 +3,11 @@ description: Suggest an improvement to Supabase CLI documentation. labels: - 📘 Docs body: + - type: markdown + attributes: + value: | + Thanks for helping improve the docs! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: input id: link attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 12bb26e54e..fd3a0eda1d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -3,6 +3,11 @@ description: Suggest an improvement or new capability for the Supabase CLI. labels: - ✨ Feature body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Open this issue **before** starting work — a maintainer will add the `open-for-contribution` label once it is triaged and ready. Pull requests from external contributors that don't link an open, `open-for-contribution` issue are closed automatically. See [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md). + - type: checkboxes id: existing-issues attributes: diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md new file mode 100644 index 0000000000..e8321dd3b9 --- /dev/null +++ b/.github/MAINTAINERS.md @@ -0,0 +1,58 @@ +# Maintainers guide + +Internal notes for maintaining the Supabase CLI contribution workflow. See +[`CONTRIBUTING.md`](../CONTRIBUTING.md) for the contributor-facing version. + +## The `open-for-contribution` gate + +External pull requests are only accepted when they link to an **open** GitHub issue +that carries the **`open-for-contribution`** label. This is enforced by the +[`Contribution Gate`](./workflows/contribution-gate.yml) workflow, whose decision logic +lives in [`scripts/contribution-gate.ts`](./scripts/contribution-gate.ts). + +The gate runs **reactively on each PR** so a non-conforming PR is closed right away, and +can also be **swept across every open PR on demand** via the workflow's *Run workflow* +button. + +A pull request is **auto-closed with an explanatory comment** when the author is external +and any of these is true: + +- no issue is linked (via a closing keyword such as `Closes #123`, or the PR's + Development sidebar), or +- the linked issue is closed, or +- the linked issue is missing the `open-for-contribution` label. + +Authors whose `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`, and bot +accounts, are **exempt** — Supabase maintainers can keep working from Linear tickets that +aren't public on GitHub. + +### Running the gate manually + +Use **Actions → Contribution Gate → Run workflow** to sweep all open PRs on demand — for +example right after bulk-applying `open-for-contribution` labels, so the whole backlog is +re-evaluated at once instead of waiting for each PR's next edit. Set the **`dry_run`** +input to `true` first to log each PR's decision in the run output without commenting on or +closing anything; run again with `dry_run` unchecked to apply the decisions. + +## Triage: applying the label (manual) + +During triage: + +1. Categorize the issue with one of `✨ Feature`, `🐛 Bug`, or `📘 Docs`. Issues opened + via the templates start with their category label already applied. +2. When the issue is ready to be worked on, add the **`open-for-contribution`** label. + +The `open-for-contribution` label must exist as a repository label for this workflow to +function; create it once from **Issues → Labels** if it is missing. + +Applying `open-for-contribution` is currently a **manual step** — do it on the GitHub +issue directly (from the GitHub UI, or from the Linear-linked issue). + +## Deferred: automatic Linear → GitHub label sync + +We considered auto-applying `open-for-contribution` when a Linear issue moves out of +Triage/Backlog (e.g. to Todo). Linear's native GitHub automations are one-directional +(GitHub events update Linear status) and cannot push a GitHub label, so this would need an +external bridge (a scheduled job polling the Linear API, a Zapier/Make zap, or a Linear +webhook → relay). It is **out of scope for now** and tracked separately; until then, apply +the label manually as above. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..2c8e119819 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Summary + + + +## Linked issue + +Closes # + +- [ ] The linked issue is **open** and carries the `open-for-contribution` label (or I'm a Supabase maintainer). + +## Checklist + +- [ ] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`). +- [ ] Tests added or updated for the change. +- [ ] `pnpm check:all` and `pnpm test` pass for the workspace(s) I touched. diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts new file mode 100644 index 0000000000..7951de817a --- /dev/null +++ b/.github/scripts/contribution-gate.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + evaluateAllOpenPrs, + evaluateGate, + fetchAuthorPermission, + GATE_LABEL, + type GateIo, + isInternalAuthor, + type LinkedIssue, + type OpenPr, +} from "./contribution-gate.ts"; + +const REPO = "supabase/cli"; + +describe("isInternalAuthor", () => { + test.each(["OWNER", "MEMBER", "COLLABORATOR"])( + "treats internal association %s as internal without a permission lookup", + (authorAssociation) => { + expect(isInternalAuthor(authorAssociation, undefined)).toBe(true); + }, + ); + + test.each(["admin", "write"])("treats push-capable permission %s as internal", (permission) => { + // A private org member surfaces only as CONTRIBUTOR/NONE via + // author_association, but their effective repo permission gives them + // away as a maintainer. + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(true); + expect(isInternalAuthor("NONE", permission)).toBe(true); + }); + + test.each(["read", "none", undefined])( + "treats external author with permission %s as external", + (permission) => { + expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(false); + expect(isInternalAuthor("NONE", permission)).toBe(false); + }, + ); +}); + +describe("evaluateGate", () => { + test("skips bot authors", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: true, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("bot"); + }); + + test("skips internal authors", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: true, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("internal"); + }); + + test("fails when no issue is linked", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + expect(result.message).toContain(GATE_LABEL); + expect(result.message).toContain("CONTRIBUTING.md"); + }); + + test("fails when the linked issue is open but not labeled", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [{ repository: REPO, number: 12, state: "OPEN", labels: ["🐛 Bug"] }], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("missing-label"); + expect(result.message).toContain(GATE_LABEL); + }); + + test("fails when the only labeled issue is closed", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [{ repository: REPO, number: 7, state: "CLOSED", labels: [GATE_LABEL] }], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("issue-closed"); + }); + + test("passes when a linked issue is open and carries the gate label", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: REPO, + number: 42, + state: "OPEN", + labels: [GATE_LABEL, "🐛 Bug"], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("passes when any one of several linked issues qualifies", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { repository: REPO, number: 1, state: "CLOSED", labels: [GATE_LABEL] }, + { repository: REPO, number: 2, state: "OPEN", labels: ["✨ Feature"] }, + { repository: REPO, number: 3, state: "OPEN", labels: [GATE_LABEL] }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); + + test("ignores an open, labeled issue from a different repository", () => { + // Cross-repo closing keyword (e.g. `Closes attacker/repo#1`): the issue is + // controlled by the contributor, so it must not satisfy the gate. + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: "attacker/repo", + number: 1, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(false); + expect(result.reason).toBe("no-linked-issue"); + }); + + test("matches the repository case-insensitively", () => { + const result = evaluateGate({ + repository: REPO, + isInternal: false, + isBot: false, + linkedIssues: [ + { + repository: "Supabase/CLI", + number: 5, + state: "OPEN", + labels: [GATE_LABEL], + }, + ], + }); + expect(result.pass).toBe(true); + expect(result.reason).toBe("ok"); + }); +}); + +describe("evaluateAllOpenPrs", () => { + function makeIo( + openPrs: OpenPr[], + linkedByPr: Record, + permissionByLogin: Record = {}, + ): { + io: GateIo; + closed: Array<{ number: number; message: string }>; + permissionLookups: string[]; + } { + const closed: Array<{ number: number; message: string }> = []; + const permissionLookups: string[] = []; + const io: GateIo = { + listOpenPrs: () => Promise.resolve(openPrs), + fetchPermission: (login) => { + permissionLookups.push(login); + return Promise.resolve(permissionByLogin[login]); + }, + fetchLinkedIssues: (prNumber) => Promise.resolve(linkedByPr[prNumber] ?? []), + closePr: (prNumber, message) => { + closed.push({ number: prNumber, message }); + return Promise.resolve(); + }, + }; + return { io, closed, permissionLookups }; + } + + test("closes only non-conforming external PRs and leaves the rest", async () => { + const { io, closed, permissionLookups } = makeIo( + [ + // no issue -> close + { number: 1, authorLogin: "ext-a", authorAssociation: "NONE", isBot: false }, + // public member -> skip (no permission lookup needed) + { number: 2, authorLogin: "maint", authorAssociation: "MEMBER", isBot: false }, + // bot -> skip + { number: 3, authorLogin: "dependabot", authorAssociation: "NONE", isBot: true }, + // conforming external -> keep + { number: 4, authorLogin: "ext-b", authorAssociation: "CONTRIBUTOR", isBot: false }, + // missing label -> close + { number: 5, authorLogin: "ext-c", authorAssociation: "NONE", isBot: false }, + // private-membership maintainer (CONTRIBUTOR + write access) -> skip + { number: 6, authorLogin: "hidden-maint", authorAssociation: "CONTRIBUTOR", isBot: false }, + ], + { + 4: [{ repository: REPO, number: 40, state: "OPEN", labels: [GATE_LABEL] }], + 5: [{ repository: REPO, number: 50, state: "OPEN", labels: ["🐛 Bug"] }], + }, + { "hidden-maint": "write" }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(closed.map((c) => c.number).sort((a, b) => a - b)).toEqual([1, 5]); + const byNumber = Object.fromEntries(entries.map((entry) => [entry.number, entry.result])); + expect(byNumber[1]?.reason).toBe("no-linked-issue"); + expect(byNumber[2]?.pass).toBe(true); + expect(byNumber[2]?.reason).toBe("internal"); + expect(byNumber[3]?.reason).toBe("bot"); + expect(byNumber[4]?.pass).toBe(true); + expect(byNumber[5]?.reason).toBe("missing-label"); + expect(byNumber[6]?.pass).toBe(true); + expect(byNumber[6]?.reason).toBe("internal"); + expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); + // Bots and public members are settled without a permission lookup. + expect(permissionLookups).not.toContain("maint"); + expect(permissionLookups).not.toContain("dependabot"); + }); + + test("returns an entry per PR and closes none when all conform", async () => { + const { io, closed } = makeIo( + [{ number: 9, authorLogin: "ext", authorAssociation: "NONE", isBot: false }], + { + 9: [{ repository: REPO, number: 90, state: "OPEN", labels: [GATE_LABEL] }], + }, + ); + + const entries = await evaluateAllOpenPrs(io, REPO); + + expect(entries).toHaveLength(1); + expect(closed).toHaveLength(0); + expect(entries[0]?.result.pass).toBe(true); + }); +}); + +describe("fetchAuthorPermission", () => { + const fetchSpy = spyOn(globalThis, "fetch"); + afterEach(() => { + fetchSpy.mockReset(); + }); + + function stubFetch(status: number, body: unknown): void { + // `typeof fetch` carries a static `preconnect`, so attach a no-op to keep + // the implementation assignable without casting. + fetchSpy.mockImplementation( + Object.assign(() => Promise.resolve(new Response(JSON.stringify(body), { status })), { + preconnect: () => {}, + }), + ); + } + + test("returns the effective permission for a collaborator", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "maint"); + expect(permission).toBe("admin"); + }); + + test("maps a 404 (non-collaborator fork author) to undefined", async () => { + // The endpoint 404s for users who are not collaborators — the common case + // for the external contributors the gate targets. It must map to external, + // not abort the run. + stubFetch(404, { message: "Not Found" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", "ext"); + expect(permission).toBeUndefined(); + }); + + test("throws on other API failures so the run aborts without closing PRs", async () => { + stubFetch(403, { message: "Forbidden" }); + await expect(fetchAuthorPermission("t", "supabase", "cli", "ext")).rejects.toThrow(/403/); + }); + + test("skips the network call for a blank login", async () => { + stubFetch(200, { permission: "admin" }); + const permission = await fetchAuthorPermission("t", "supabase", "cli", ""); + expect(permission).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts new file mode 100644 index 0000000000..a2b62ee08f --- /dev/null +++ b/.github/scripts/contribution-gate.ts @@ -0,0 +1,505 @@ +/** + * Contribution gate: enforces the Supabase CLI contribution workflow across all + * OPEN pull requests opened by external contributors. + * + * A PR passes only when it links to an OPEN GitHub issue that carries the + * `open-for-contribution` label. Maintainers (recognised by their effective + * repository permission, so private org members count too — not just the + * `author_association` GitHub exposes for public members) and bots are exempt + * (they work from Linear tickets or automation). PRs that do not follow the + * process are commented on and closed. + * + * Two modes, both driven from `main()`: + * - single-PR (default): reacts to one PR on `pull_request_target` + * (`opened`/`reopened`/`edited`), using the PR_* env vars from the event. + * - all-PRs sweep (`GATE_MODE=all`, or the `--all` flag): evaluates every + * open PR, for on-demand `workflow_dispatch` runs. Set `DRY_RUN=true` to + * log decisions without commenting/closing. + * + * In both modes the workflow checks out the base branch, so this only ever + * executes trusted repository code — it never runs a fork's code. + * + * Run in CI as: `bun .github/scripts/contribution-gate.ts`. + * The pure `evaluateGate` decision and the `evaluateAllOpenPrs` orchestrator + * (I/O injected) are unit-tested in `contribution-gate.test.ts`; `main()` wires + * up the real GitHub I/O. + */ + +export const GATE_LABEL = "open-for-contribution"; + +/** + * Author associations treated as internal (exempt from the gate). + * + * NOTE: `author_association` only reports `MEMBER` when a user's organization + * membership is *public*. A private org member (or a team member who keeps + * their membership private) is reported as `CONTRIBUTOR`/`NONE`, so this set + * alone is not enough to identify internal maintainers — see + * `isInternalAuthor`, which also consults the author's effective repository + * permission. + */ +export const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +/** + * Effective repository permissions that mark an author as internal. A user who + * can push to the repository (directly, or via a team/org grant that + * `author_association` does not surface) is a trusted maintainer, not an + * external contributor. The legacy REST `permission` field collapses the + * `maintain` role to `write`, so `admin`/`write` covers every push-capable + * role. + */ +const WRITE_PERMISSIONS = new Set(["admin", "write"]); + +/** + * Decide whether a PR author is internal (exempt from the gate). Combines the + * cheap `author_association` signal with the author's effective repository + * `permission` (undefined when it could not be resolved), so private org + * members whose association is merely `CONTRIBUTOR` are still recognised. + */ +export function isInternalAuthor( + authorAssociation: string, + permission: string | undefined, +): boolean { + return ( + INTERNAL_ASSOCIATIONS.has(authorAssociation) || + (permission !== undefined && WRITE_PERMISSIONS.has(permission)) + ); +} + +export interface LinkedIssue { + /** `owner/name` of the repo the issue lives in (from GraphQL nameWithOwner). */ + repository: string; + number: number; + state: "OPEN" | "CLOSED"; + labels: string[]; +} + +export interface GateInput { + /** `owner/name` of this repository (from GITHUB_REPOSITORY). */ + repository: string; + /** Whether the author is a trusted maintainer (see `isInternalAuthor`). */ + isInternal: boolean; + isBot: boolean; + linkedIssues: LinkedIssue[]; +} + +export type GateReason = + | "bot" + | "internal" + | "ok" + | "no-linked-issue" + | "missing-label" + | "issue-closed"; + +export interface GateResult { + pass: boolean; + reason: GateReason; + /** Explanatory comment body, present only when `pass` is false. */ + message?: string; +} + +const DOCS_FOOTER = + `\nSee [CONTRIBUTING.md](../blob/develop/CONTRIBUTING.md) for the full workflow. ` + + `Once a maintainer adds the \`${GATE_LABEL}\` label to a linked open issue, ` + + `reopen or open a new pull request and it will be accepted.`; + +function buildMessage(reason: GateReason): string { + switch (reason) { + case "no-linked-issue": + return ( + `👋 Thanks for the contribution! This pull request isn't linked to a ` + + `tracked issue, so it's being closed automatically.\n\n` + + `Please open an issue first, wait for a maintainer to add the ` + + `\`${GATE_LABEL}\` label, then open a pull request that links the issue ` + + `with a closing keyword (e.g. \`Closes #123\`).${DOCS_FOOTER}` + ); + case "missing-label": + return ( + `👋 Thanks! The linked issue hasn't been marked \`${GATE_LABEL}\` yet, ` + + `so this pull request is being closed automatically.\n\n` + + `A maintainer adds that label once an issue is triaged and ready to be ` + + `worked on. Please wait for the label before opening a pull ` + + `request.${DOCS_FOOTER}` + ); + case "issue-closed": + return ( + `👋 The issue linked to this pull request is closed, so it's being ` + + `closed automatically.\n\n` + + `Please link an open issue that carries the \`${GATE_LABEL}\` ` + + `label.${DOCS_FOOTER}` + ); + default: + return ""; + } +} + +/** + * Pure decision function for the contribution gate. Given the PR author context + * and its linked issues, returns whether the PR is allowed and why. + */ +export function evaluateGate(input: GateInput): GateResult { + if (input.isBot) { + return { pass: true, reason: "bot" }; + } + if (input.isInternal) { + return { pass: true, reason: "internal" }; + } + + // Only issues in THIS repository count. A cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, + // so it must never satisfy the gate. + const repo = input.repository.toLowerCase(); + const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); + + if (repoIssues.length === 0) { + return { + pass: false, + reason: "no-linked-issue", + message: buildMessage("no-linked-issue"), + }; + } + + const qualifies = repoIssues.some( + (issue) => issue.state === "OPEN" && issue.labels.includes(GATE_LABEL), + ); + if (qualifies) { + return { pass: true, reason: "ok" }; + } + + const hasOpenIssue = repoIssues.some((issue) => issue.state === "OPEN"); + const reason: GateReason = hasOpenIssue ? "missing-label" : "issue-closed"; + return { pass: false, reason, message: buildMessage(reason) }; +} + +/** Minimal open-PR shape the gate needs to decide exemption. */ +export interface OpenPr { + number: number; + authorLogin: string; + authorAssociation: string; + isBot: boolean; +} + +/** Injected GitHub I/O so the sweep can be unit-tested without the network. */ +export interface GateIo { + /** List every open pull request in the repository. */ + listOpenPrs: () => Promise; + /** + * Resolve an author's effective repository permission (`admin`/`write`/ + * `read`/`none`), or `undefined` when it could not be determined. Used to + * recognise maintainers whose org membership is private. + */ + fetchPermission: (login: string) => Promise; + /** Fetch the issues a PR closes (via `closingIssuesReferences`). */ + fetchLinkedIssues: (prNumber: number) => Promise; + /** Comment with `message` then close the PR. Called only for failing PRs. */ + closePr: (prNumber: number, message: string) => Promise; +} + +export interface SweepEntry { + number: number; + result: GateResult; +} + +/** + * Evaluate the gate against every open PR, closing the ones that fail. Pure + * orchestration over the injected `io`; returns each PR's decision so the + * caller can log a summary. + */ +export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promise { + const openPrs = await io.listOpenPrs(); + const entries: SweepEntry[] = []; + for (const pr of openPrs) { + // Only pay for a permission lookup when the cheap signals are inconclusive: + // bots are exempt outright, and a public internal association already + // proves membership. + let isInternal = pr.isBot || INTERNAL_ASSOCIATIONS.has(pr.authorAssociation); + if (!isInternal) { + const permission = await io.fetchPermission(pr.authorLogin); + isInternal = isInternalAuthor(pr.authorAssociation, permission); + } + const linkedIssues = await io.fetchLinkedIssues(pr.number); + const result = evaluateGate({ + repository, + isInternal, + isBot: pr.isBot, + linkedIssues, + }); + if (!result.pass && result.message) { + await io.closePr(pr.number, result.message); + } + entries.push({ number: pr.number, result }); + } + return entries; +} + +// --- GitHub I/O (only runs when executed directly) --- + +interface GraphQLIssueNode { + number: number; + state: "OPEN" | "CLOSED"; + repository: { nameWithOwner: string }; + labels: { nodes: Array<{ name: string }> }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function githubFetch( + url: string, + token: string, + init: Omit = {}, + /** Non-OK statuses to return to the caller instead of throwing on. */ + allowStatuses: readonly number[] = [], +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + }); + if (!response.ok && !allowStatuses.includes(response.status)) { + const body = await response.text(); + throw new Error(`GitHub request failed (${response.status}) for ${url}: ${body}`); + } + return response; +} + +async function fetchLinkedIssues( + token: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { + number + state + repository { nameWithOwner } + labels(first: 50) { nodes { name } } + } + } + } + } + }`; + const response = await githubFetch("https://api.github.com/graphql", token, { + method: "POST", + body: JSON.stringify({ + query, + variables: { owner, repo, number: prNumber }, + }), + }); + const payload = (await response.json()) as { + errors?: Array<{ message: string }>; + data?: { + repository?: { + pullRequest?: { + closingIssuesReferences?: { nodes?: GraphQLIssueNode[] }; + }; + }; + }; + }; + if (payload.errors?.length) { + throw new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`); + } + const nodes = payload.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + return nodes.map((node) => ({ + repository: node.repository.nameWithOwner, + number: node.number, + state: node.state, + labels: node.labels.nodes.map((label) => label.name), + })); +} + +interface RestPullRequest { + number: number; + author_association: string; + user: { login: string; type: string } | null; +} + +async function fetchOpenPullRequests( + token: string, + owner: string, + repo: string, +): Promise { + const prs: OpenPr[] = []; + for (let page = 1; ; page++) { + const url = + `https://api.github.com/repos/${owner}/${repo}/pulls` + + `?state=open&per_page=100&page=${page}`; + const response = await githubFetch(url, token); + const batch = (await response.json()) as RestPullRequest[]; + for (const pr of batch) { + prs.push({ + number: pr.number, + authorLogin: pr.user?.login ?? "", + authorAssociation: pr.author_association, + isBot: pr.user?.type === "Bot", + }); + } + if (batch.length < 100) { + break; + } + } + return prs; +} + +/** + * Resolve an author's effective permission on the repository. Reflects access + * granted directly or through a team/org membership, so it recognises private + * org members that `author_association` reports only as `CONTRIBUTOR`. This + * endpoint needs just `Metadata: read` (covered by the workflow's + * `contents: read`). + * + * A 404 means the author is not a collaborator at all — the common case for + * external fork contributors, who are exactly who the gate targets — so it maps + * to `undefined` (external) rather than throwing. Other failures still throw so + * a transient API error aborts the run without wrongly closing PRs. + */ +export async function fetchAuthorPermission( + token: string, + owner: string, + repo: string, + login: string, +): Promise { + if (!login) { + return undefined; + } + const url = + `https://api.github.com/repos/${owner}/${repo}/collaborators/` + + `${encodeURIComponent(login)}/permission`; + const response = await githubFetch(url, token, {}, [404]); + if (response.status === 404) { + return undefined; + } + const payload = (await response.json()) as { permission?: string }; + return payload.permission; +} + +/** + * Build the "comment then close" action for a repo. In dry-run mode it logs the + * intended action instead of mutating the PR. + */ +function makeCloser( + token: string, + base: string, + dryRun: boolean, +): (prNumber: number, message: string) => Promise { + return async (prNumber, message) => { + if (dryRun) { + console.log(`[dry-run] would comment on and close PR #${prNumber}`); + return; + } + await githubFetch(`${base}/issues/${prNumber}/comments`, token, { + method: "POST", + body: JSON.stringify({ body: message }), + }); + await githubFetch(`${base}/issues/${prNumber}`, token, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + }); + }; +} + +/** Single-PR mode: react to the PR carried by a `pull_request_target` event. */ +async function runSinglePr( + token: string, + owner: string, + repo: string, + repository: string, +): Promise { + const prNumber = Number(requireEnv("PR_NUMBER")); + const authorAssociation = process.env.PR_AUTHOR_ASSOCIATION ?? "NONE"; + const authorLogin = process.env.PR_AUTHOR_LOGIN ?? ""; + const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; + + // Resolve maintainer status from the effective repository permission unless a + // cheaper signal already settles it. `author_association` only exposes public + // org membership, so a private member must be confirmed via permission. + let permission: string | undefined; + let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); + if (!isInternal) { + permission = await fetchAuthorPermission(token, owner, repo, authorLogin); + isInternal = isInternalAuthor(authorAssociation, permission); + } + + const linkedIssues = await fetchLinkedIssues(token, owner, repo, prNumber); + const result = evaluateGate({ + repository, + isInternal, + isBot, + linkedIssues, + }); + + console.log( + `Contribution gate for PR #${prNumber}: pass=${result.pass} reason=${result.reason} ` + + `(author_association=${authorAssociation}, permission=${permission ?? "n/a"}, ` + + `bot=${isBot}, linked_issues=${linkedIssues.length})`, + ); + + if (result.pass || !result.message) { + return; + } + + const base = `https://api.github.com/repos/${owner}/${repo}`; + await makeCloser(token, base, false)(prNumber, result.message); + console.log(`Closed PR #${prNumber} (reason=${result.reason}).`); +} + +/** All-PRs mode: sweep every open PR, for on-demand `workflow_dispatch` runs. */ +async function runSweep( + token: string, + owner: string, + repo: string, + repository: string, + dryRun: boolean, +): Promise { + const base = `https://api.github.com/repos/${owner}/${repo}`; + const io: GateIo = { + listOpenPrs: () => fetchOpenPullRequests(token, owner, repo), + fetchPermission: (login) => fetchAuthorPermission(token, owner, repo, login), + fetchLinkedIssues: (prNumber) => fetchLinkedIssues(token, owner, repo, prNumber), + closePr: makeCloser(token, base, dryRun), + }; + + const entries = await evaluateAllOpenPrs(io, repository); + const failing = entries.filter((entry) => !entry.result.pass); + console.log( + `Contribution gate sweep: ${entries.length} open PR(s) evaluated, ` + + `${failing.length} ${dryRun ? "would be " : ""}closed.`, + ); + for (const entry of entries) { + console.log(` PR #${entry.number}: pass=${entry.result.pass} reason=${entry.result.reason}`); + } +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const repository = requireEnv("GITHUB_REPOSITORY"); + const [owner, repo] = repository.split("/"); + + const allMode = process.env.GATE_MODE === "all" || process.argv.includes("--all"); + if (allMode) { + const dryRun = /^(1|true)$/i.test(process.env.DRY_RUN ?? ""); + await runSweep(token, owner!, repo!, repository, dryRun); + } else { + await runSinglePr(token, owner!, repo!, repository); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 8faf9d0377..af7110def1 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -81,10 +81,18 @@ jobs: run: go mod download -x - name: Install nfpm + env: + NFPM_VERSION: "2.47.0" + NFPM_SHA256: "0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783" run: | - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt-get update - sudo apt-get install -y nfpm + set -euo pipefail + asset="nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" + curl -fsSL -o /tmp/nfpm.tar.gz \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/${asset}" + echo "${NFPM_SHA256} /tmp/nfpm.tar.gz" | sha256sum -c + tar -xzf /tmp/nfpm.tar.gz -C /tmp nfpm + sudo install -m 0755 /tmp/nfpm /usr/local/bin/nfpm + nfpm --version - name: Install rcodesign env: diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 3a7c41cbed..9759f2f7e6 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" defaults: diff --git a/.github/workflows/cli-go-mirror-image.yml b/.github/workflows/cli-go-mirror-image.yml index 3c9484e266..2844079487 100644 --- a/.github/workflows/cli-go-mirror-image.yml +++ b/.github/workflows/cli-go-mirror-image.yml @@ -34,19 +34,19 @@ jobs: run: | echo "image=${TAG##*/}" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: role-to-assume: ${{ secrets.PROD_AWS_ROLE }} aws-region: us-east-1 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: public.ecr.aws - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Mirror image env: SOURCE_IMAGE: docker.io/${{ github.event.client_payload.image || inputs.image }} diff --git a/.github/workflows/cli-go-pg-prove.yml b/.github/workflows/cli-go-pg-prove.yml index 14202acc5c..323758c1c0 100644 --- a/.github/workflows/cli-go-pg-prove.yml +++ b/.github/workflows/cli-go-pg-prove.yml @@ -11,8 +11,8 @@ jobs: outputs: image_tag: supabase/pg_prove:${{ steps.version.outputs.pg_prove }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/horrendo/pg_prove.git @@ -42,15 +42,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/horrendo/pg_prove.git @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cli-go-publish-migra.yml b/.github/workflows/cli-go-publish-migra.yml index 5220a0562d..11b6437666 100644 --- a/.github/workflows/cli-go-publish-migra.yml +++ b/.github/workflows/cli-go-publish-migra.yml @@ -11,8 +11,8 @@ jobs: outputs: image_tag: supabase/migra:${{ steps.version.outputs.migra }} steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: load: true context: https://github.com/djrobstep/migra.git @@ -42,15 +42,15 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - run: docker context create builders - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: endpoint: builders - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: push: true context: https://github.com/djrobstep/migra.git @@ -66,8 +66,8 @@ jobs: - build_image runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml new file mode 100644 index 0000000000..6dfeee2bf0 --- /dev/null +++ b/.github/workflows/contribution-gate.yml @@ -0,0 +1,75 @@ +name: Contribution Gate + +on: + pull_request_target: + types: + - opened + - reopened + - edited + # Manual sweep over every open PR — e.g. after bulk-applying the + # `open-for-contribution` label. Set `dry_run` to log decisions without + # commenting or closing. + workflow_dispatch: + inputs: + dry_run: + description: "Evaluate and log decisions only; do not comment or close" + type: boolean + default: false + +permissions: + pull-requests: write + issues: read + contents: read + +concurrency: + # Per-PR for pull_request_target; unique per run for manual sweeps (which + # carry no pull_request payload) so two sweeps never cancel each other. + group: contribution-gate-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + gate-single: + name: Contribution Gate + runs-on: ubuntu-latest + # Exempt maintainers/collaborators and bots. External contributors are gated. + if: > + github.event_name == 'pull_request_target' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'COLLABORATOR' && + github.event.pull_request.user.type != 'Bot' + steps: + # Checks out the base repo (default for pull_request_target), so the gate + # runs trusted code from the base branch, never the untrusted PR head. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + run: bun .github/scripts/contribution-gate.ts + + gate-all: + name: Contribution Gate (all open PRs) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + # Base-branch checkout only — the sweep makes API calls and never runs + # any PR's code. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.13" + - name: Evaluate contribution gate across all open PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GATE_MODE: all + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: bun .github/scripts/contribution-gate.ts diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 40fa3be500..8f46d54288 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -50,7 +50,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Log in to ghcr.io - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 79a3175805..a2e30ccfe0 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -160,7 +160,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Verify linux/arm64 emulation is registered if: runner.os == 'Linux' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4312076c56..cb8978cf1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,18 @@ Bun monorepo for exploring the next generation of the Supabase CLI and local development stack. +## Contribution workflow + +Before you open a pull request: + +1. **Open an issue first**, using one of the [issue templates](https://github.com/supabase/cli/issues/new/choose). +2. **Wait for maintainer triage.** A maintainer categorizes the issue (`✨ Feature`, `🐛 Bug`, or `📘 Docs`) and adds the **`open-for-contribution`** label once it is ready to be worked on. +3. **Open a pull request only after the `open-for-contribution` label is set**, and link the issue with a closing keyword (for example `Closes #123`). + +Until the `open-for-contribution` label is present, the issue is still in triage, so work should not start and a pull request should not be opened. + +Pull requests from external contributors that do not follow this workflow are commented on and closed automatically by the [Contribution Gate](.github/workflows/contribution-gate.yml). Supabase members are exempt, so they can work from Linear tickets that are not public on GitHub. Maintainers: see [`.github/MAINTAINERS.md`](.github/MAINTAINERS.md). + ## Setup ### Tool versions diff --git a/README.md b/README.md index 1e0b1d528e..9652d3738b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,11 @@ pnpm repos:install ## Contributing -We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. Before opening a PR, run the checks for the workspace you touched. +We love focused pull requests with a clear problem, a small surface area, and tests that match the user-facing behavior. + +Open an issue first and wait for a maintainer to add the `open-for-contribution` label before starting work — external pull requests that don't link a labeled, open issue are closed automatically. See [CONTRIBUTING.md](./CONTRIBUTING.md#contribution-workflow) for the full workflow. + +Before opening a PR, run the checks for the workspace you touched. ```sh pnpm check:all diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts new file mode 100644 index 0000000000..9c2ec6f8e7 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-backend live coverage for the native `db start` / `db reset` ports. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the +// destructive remote reset below is safe against the throwaway per-run project. + +// --- Local leg: db start + db reset --local against the real Docker socket ----- +// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the +// in-process integration suites mock. The start → already-running → reset cycle +// runs in one test so it shares a single booted stack, and `finally` stops it +// (legacy proxies `stop` to Go) so the run never leaves containers behind. +describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); + +// --- Remote leg: db reset against the staging project over the session pooler --- +// Exercises the native remote reset path (drop user schemas → apply local +// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the +// confirmation prompt (the non-interactive default is decline). Mutates the +// throwaway project's schema — deleted on teardown. The IPv4 session pooler +// `dbUrl` is used because the direct host is IPv6-only and unreachable from +// IPv4-only CI runners. +describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { + testLive( + "resets the remote schema and re-applies a local migration", + { timeout: 600_000 }, + async ({ run, dbUrl, workspace }) => { + const migrations = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrations, { recursive: true }); + writeFileSync( + join(migrations, "20240101000000_e2e_reset.sql"), + "create table if not exists e2e_reset (id int);\n", + ); + + const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("Resetting remote database"); + // A real connection failure must never be mistaken for a benign outcome. + expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + + // The migration history shows the re-applied version → proves the drop + + // migrate ran against the remote database. + const listed = await run(["migration", "list", "--db-url", dbUrl]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(listed.stdout).toContain("20240101000000"); + }, + ); +}); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 0e3dbc932f..45b02ef3bc 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -45,7 +45,10 @@ describe("migrations", () => { testBehaviour("exits non-zero without name argument", async ({ run }) => { const result = await run(["migration", "new"]); expect(result.exitCode).not.toBe(0); - expect(result.stdout).toContain("migration name"); + // CLI-1901: a missing positional argument's usage block now prints to + // stderr (never stdout) instead of being duplicated across both. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); }); }); @@ -90,7 +93,11 @@ describe("migrations", () => { testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { const result = await run(["migration", "repair", "--local", "20230101000000"]); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--status"); + // CLI-1901: a missing required flag now drops the vendored library's + // duplicate usage dump entirely (Go's cobra suppresses usage for this + // case too), leaving only this repo's existing Go-parity error line, + // which spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); }); testBehaviour("exits non-zero on connection refused", async ({ run }) => { diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..66df4311cc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -270,6 +271,71 @@ var ( }, } + bootstrapMode string + bootstrapSqlPaths []string + bootstrapFromBackup string + bootstrapVersion string + bootstrapNoSeed bool + + // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and + // `db reset --local` commands to drive the container-bootstrap primitives that + // are not yet ported to TypeScript: creating/recreating the local Postgres + // container, applying the initial schema, and the storage health gate. The TS + // caller orchestrates everything else (the "already running?" check and its + // message, version/last resolution, bucket seeding, the git-branch "Finished…" + // line, telemetry, and --output-format shaping); the seam stays in Go only for + // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + // db-url/local/linked target flags, so it loads supabase/config.toml explicitly + // (the root PersistentPreRunE only loads it when a target flag is set). Progress + // goes to stderr; the only stdout output is a single machine-parseable marker + // for --mode await-storage ("ready" or "absent"). + dbBootstrapCmd = &cobra.Command{ + Use: "__db-bootstrap", + Hidden: true, + Short: "Internal: container bootstrap for the native db start / db reset commands", + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := flags.LoadConfig(fsys); err != nil { + return err + } + switch bootstrapMode { + case "start": + // Mirror start.Run minus the "already running?" check, which the TS + // caller performs (and prints "Postgres database is already running."). + if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { + if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { + fmt.Fprintln(os.Stderr, rmErr) + } + return err + } + return nil + case "recreate": + // The PG14/PG15 container-recreate half of local db reset. The TS + // caller has already printed "Resetting local database…" and validated + // the flags. Apply the same seed handling as `db reset` (dbResetCmd): + // `--no-seed` disables the seed, `--sql-paths` overrides the seed paths, + // before MigrateAndSeed runs inside the recreate. + if err := applyDbResetSeedFlags(bootstrapNoSeed, bootstrapSqlPaths); err != nil { + return err + } + return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) + case "await-storage": + ready, err := reset.AwaitStorageReady(cmd.Context()) + if err != nil { + return err + } + if ready { + fmt.Println("ready") + } else { + fmt.Println("absent") + } + return nil + default: + return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) + } + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -620,6 +686,14 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) + // Build hidden container-bootstrap seam command (native db start / db reset) + bootstrapFlags := dbBootstrapCmd.Flags() + bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") + bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") + bootstrapFlags.StringArrayVar(&bootstrapSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for the recreate (recreate mode).") + dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 82b8ea4526..ede19cb805 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -42,13 +42,13 @@ require ( github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.17.2 + github.com/posthog/posthog-go v1.17.5 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/stripe/pg-schema-diff v1.0.5 + github.com/stripe/pg-schema-diff v1.0.7 github.com/supabase/cli/pkg v1.0.0 github.com/tidwall/jsonc v0.3.3 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 @@ -58,7 +58,7 @@ require ( golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.44.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index f929d36494..a9cb686621 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -970,8 +970,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.17.2 h1:w0TaCAd+Z3WoEgVyR/nlcXlqNN2tpoBfIyxuGssDgCE= -github.com/posthog/posthog-go v1.17.2/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= +github.com/posthog/posthog-go v1.17.5 h1:mrLiAdyiQpl8Yeyg23iShyAztJMaUrYzsBjKeH52Aak= +github.com/posthog/posthog-go v1.17.5/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -1111,8 +1111,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/pg-schema-diff v1.0.5 h1:TNHkiRNMn7ttiBd+YBypAbx9v0SfVls+NQZFtamy1K4= -github.com/stripe/pg-schema-diff v1.0.5/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= +github.com/stripe/pg-schema-diff v1.0.7 h1:aVMFqjsnPSeh46hlJnexGPm28nZJUvZK0bEyk6rdFVE= +github.com/stripe/pg-schema-diff v1.0.7/go.mod h1:3IctPaAqm+0LtWw/GiwyRoRlU1/N/+00+eXVk0KZIHs= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= @@ -1441,8 +1441,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7d841f4ba3..765153d6d7 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -92,6 +92,38 @@ func toLogMessage(version string) string { return "..." } +// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, +// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). +// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, +// migrate + seed, and restart the satellite containers — WITHOUT the leading +// "Resetting local database…" line, which the TS caller prints itself. Mirrors +// resetDatabase (above) minus that message. +func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion <= 14 { + return resetDatabase14(ctx, version, fsys, options...) + } + return resetDatabase15(ctx, version, fsys, options...) +} + +// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs +// before seeding buckets (Run, above): if the storage container exists but is not +// healthy, wait up to 30s for it. It reports whether the storage container exists +// so the native-TypeScript caller knows whether to run the (already-ported) bucket +// seeding. Any inspect error is treated as "storage not running" → false, matching +// Go's `err == nil` gate, which silently skips buckets on any inspect failure. +func AwaitStorageReady(ctx context.Context) (bool, error) { + resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) + if err != nil { + return false, nil + } + if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { + if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + return false, err + } + } + return true, nil +} + func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli-go/internal/functions/download/download.go b/apps/cli-go/internal/functions/download/download.go index 74d0c13e4d..3e3737ba1e 100644 --- a/apps/cli-go/internal/functions/download/download.go +++ b/apps/cli-go/internal/functions/download/download.go @@ -23,6 +23,7 @@ import ( "github.com/docker/docker/api/types/network" "github.com/docker/go-units" "github.com/go-errors/errors" + "github.com/google/uuid" "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" @@ -35,6 +36,15 @@ var ( legacyImportMapPath = "file:///src/import_map.json" ) +// ErrUnsafeDownloadPath is returned when a downloaded Function file's path, +// as reported by the server via the Supabase-Path header or the +// Content-Disposition filename, would resolve outside utils.FunctionsDir +// once joined and cleaned, or would require following an existing symlink +// to get there. The server response is not trusted input, so any path that +// looks like it's trying to escape the functions directory is rejected +// rather than sanitized. +var ErrUnsafeDownloadPath = errors.New("invalid path in server response") + func RunLegacy(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error { // 1. Sanity checks. { @@ -160,6 +170,22 @@ func downloadAll(ctx context.Context, projectRef string, fsys afero.Fs, download fmt.Fprintf(os.Stderr, "Found %d function(s) to download\n", len(functions)) for _, f := range functions { + // f.Slug comes straight from the Management API response, which + // this threat model treats as untrusted: a malicious or corrupted + // response (or a MITM) could return a slug containing ".." or "/" + // segments. Every downloader below joins this value into a + // filesystem path (utils.TempDir for downloadOne, utils.FunctionsDir + // for downloadWithServerSideUnbundle) before any validation of its + // own, so it must be rejected here -- the single point where it + // enters this dispatch logic -- rather than relying on each + // downstream path-construction site to defend itself. + if err := utils.ValidateFunctionSlug(f.Slug); err != nil { + utils.CmdSuggestion = fmt.Sprintf( + "The Supabase API returned an unexpected function slug (%s). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.", + utils.Aqua(f.Slug), + ) + return errors.Errorf("failed to download function %s: %w", f.Slug, err) + } if err := downloader(ctx, f.Slug, projectRef, fsys); err != nil { return err } @@ -289,6 +315,10 @@ func suggestLegacyBundle(slug string) string { return fmt.Sprintf("\nIf your function is deployed using CLI < 1.120.0, trying running %s instead.", utils.Aqua("supabase functions download --legacy-bundle "+slug)) } +func suggestUnsafeDownloadPath() string { + return "This usually indicates a malformed or unexpected API response. If you're using a self-hosted instance, verify your API URL is correct." +} + type bundleMetadata struct { EntrypointPath string `json:"deno2_entrypoint_path,omitempty"` } @@ -401,17 +431,196 @@ func saveFile(file *multipart.FileHeader, entrypointPath, funcDir string, fsys a relPath, err := filepath.Rel(filepath.FromSlash(entrypointPath), filepath.FromSlash(partPath)) if err != nil { - // Continue extracting without entrypoint - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), err) + // Continue extracting without entrypoint. Go's Rel error embeds + // both paths verbatim ("Rel: can't make relative to + // "), and partPath is server-controlled, so this is gated + // behind --debug like the "Resolving file path" log above rather + // than printed unconditionally to stderr. + fmt.Fprintln(logger, "WARNING:", err) relPath = filepath.FromSlash(path.Join("..", partPath)) } + // partPath (and therefore relPath and dstPath) is derived from + // server-controlled multipart metadata (Supabase-Path header or + // Content-Disposition filename), so it must be validated before it + // touches the filesystem below. dstPath := filepath.Join(funcDir, path.Base(entrypointPath), relPath) + + // Containment is enforced against the shared functions directory + // rather than funcDir: the entrypoint-mismatch fallback above can + // legitimately resolve one level above funcDir (into a sibling + // function's directory), but must never resolve outside + // utils.FunctionsDir entirely, e.g. via a "../../../etc/passwd" style + // payload. + root := utils.FunctionsDir + if err := validateDownloadPath(root, dstPath); err != nil { + utils.CmdSuggestion = suggestUnsafeDownloadPath() + return err + } + + // A previous part could have planted a symlink at an intermediate + // directory component, which would otherwise let MkdirAll/OpenFile + // silently follow it out of root even though dstPath itself looks + // clean. Check before creating the directory, and again after the + // write lands in case a symlink was swapped in between the two checks. + dstDir := filepath.Dir(dstPath) + if err := ensureNoSymlinkInPath(fsys, root, dstDir); err != nil { + utils.CmdSuggestion = suggestUnsafeDownloadPath() + return err + } + if err := utils.MkdirIfNotExistFS(fsys, dstDir); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Extracting file:", dstPath) - if err := afero.WriteReader(fsys, dstPath, part); err != nil { + if err := writeFileNoFollowSymlink(fsys, dstPath, part); err != nil { + return err + } + + if err := ensureNoSymlinkInPath(fsys, root, dstPath); err != nil { + utils.CmdSuggestion = suggestUnsafeDownloadPath() + return err + } + return nil +} + +// validateDownloadPath rejects dstPath if, once lexically cleaned, it does +// not resolve inside root. This is the primary defense against a hostile +// "../../etc/passwd" style Supabase-Path/filename escaping the functions +// directory: filepath.Join already cleans ".." segments away, so this +// check must run against the cleaned result rather than by scanning the +// raw, attacker-controlled path for "..". +func validateDownloadPath(root, dstPath string) error { + rel, err := filepath.Rel(root, filepath.Clean(dstPath)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return errors.Errorf("failed to save file: %w", ErrUnsafeDownloadPath) + } + return nil +} + +// ensureNoSymlinkInPath rejects dir if resolving the symlinks along it would +// land outside root. +// +// Earlier versions of this check rejected the mere presence of a symlink +// anywhere under root, which also broke legitimate setups such as a +// monorepo symlinking a shared directory into place inside the functions +// tree. Instead, this resolves both root and dir the same way -- walking up +// to the deepest ancestor that already exists on disk, following any chain +// of symlinks there via filepath.EvalSymlinks (this also naturally covers +// root itself being a symlink, since dir's walk passes through root), and +// re-joining the still-nonexistent remainder back on -- and re-validates +// the two resolved paths against each other with validateDownloadPath. +// Resolving root the same way dir is resolved, rather than comparing a +// resolved dir against a literal root, matters in practice: it is what +// keeps an OS-level symlink that sits above both of them (e.g. macOS's +// /var -> /private/var, which every path under a t.TempDir() passes +// through) from producing a spurious mismatch. Only a resolved dir that +// escapes the resolved root is rejected; a symlink whose target still +// lands inside root, however deeply nested, is now allowed. +// +// This only has an effect on filesystems that expose real symlink +// semantics through afero.LinkReader, i.e. afero.NewOsFs in production +// (afero.Lstater is not a reliable enough signal on its own: afero.MemMapFs +// also implements it, just by delegating straight to Stat). The in-memory +// filesystem used in tests implements neither, so this check is +// effectively a no-op there -- there is nothing to protect against on a +// filesystem that cannot contain symlinks in the first place. That same +// guard is also why it is safe for filepath.EvalSymlinks below to hit the +// real OS filesystem directly instead of going through fsys: the only +// afero.Fs implementation used in production, afero.NewOsFs, delegates to +// the real OS filesystem for every operation, so the two agree. +func ensureNoSymlinkInPath(fsys afero.Fs, root, dir string) error { + if _, ok := fsys.(afero.LinkReader); !ok { + return nil + } + + // filepath.EvalSymlinks returns an absolute result as soon as it + // crosses one absolute symlink, but stays relative otherwise (per its + // doc comment), so root and dir must both start out absolute here -- + // otherwise root (no symlink crossed) and dir (crossing one) could + // resolve to a relative and an absolute path respectively, which + // filepath.Rel below cannot meaningfully compare. + absRoot, err := filepath.Abs(root) + if err != nil { + return errors.Errorf("failed to inspect extraction path: %w", err) + } + absDir, err := filepath.Abs(dir) + if err != nil { + return errors.Errorf("failed to inspect extraction path: %w", err) + } + + resolvedRoot, err := resolveExistingPath(fsys, absRoot) + if err != nil { + return errors.Errorf("failed to inspect extraction path: %w", err) + } + resolvedDir, err := resolveExistingPath(fsys, absDir) + if err != nil { + return errors.Errorf("failed to inspect extraction path: %w", err) + } + + return validateDownloadPath(resolvedRoot, resolvedDir) +} + +// resolveExistingPath resolves p by walking up to the deepest ancestor of it +// that already exists -- a path component that does not exist yet cannot +// itself be a symlink, so there is nothing there for EvalSymlinks to +// resolve -- following any chain of symlinks in that ancestor to its real +// target, then re-joining the still-nonexistent remainder of p back onto +// the result. +func resolveExistingPath(fsys afero.Fs, p string) (string, error) { + existing := p + var suffix []string + for { + if _, err := fsys.Stat(existing); err == nil { + break + } else if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(existing) + if parent == existing { + // Reached the filesystem root without finding anything that + // exists yet, so there is nothing left to resolve. + return filepath.Join(append([]string{existing}, suffix...)...), nil + } + suffix = append([]string{filepath.Base(existing)}, suffix...) + existing = parent + } + + resolved, err := filepath.EvalSymlinks(existing) + if err != nil { + return "", err + } + return filepath.Join(append([]string{resolved}, suffix...)...), nil +} + +// writeFileNoFollowSymlink writes r to dstPath without following a symlink +// that might already occupy that path. It creates a randomly named +// temporary file next to dstPath with O_EXCL, refusing to write through +// anything already there, then atomically renames it onto dstPath. +// Rename replaces whatever directory entry currently exists at dstPath -- +// including a symlink -- rather than dereferencing it, which is exactly +// what a plain fs.Create/afero.WriteReader would otherwise do. +func writeFileNoFollowSymlink(fsys afero.Fs, dstPath string, r io.Reader) error { + tmpPath := filepath.Join(filepath.Dir(dstPath), fmt.Sprintf(".supabase-download-%s.tmp", uuid.NewString())) + tmp, err := fsys.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { return errors.Errorf("failed to save file: %w", err) } + if _, err := io.Copy(tmp, r); err != nil { + _ = tmp.Close() + _ = fsys.Remove(tmpPath) + return errors.Errorf("failed to save file: %w", err) + } + if err := tmp.Close(); err != nil { + _ = fsys.Remove(tmpPath) + return errors.Errorf("failed to save file: %w", err) + } + + if err := fsys.Rename(tmpPath, dstPath); err != nil { + _ = fsys.Remove(tmpPath) + return errors.Errorf("failed to save file: %w", err) + } return nil } diff --git a/apps/cli-go/internal/functions/download/download_test.go b/apps/cli-go/internal/functions/download/download_test.go index 6ec601d928..355b0ec39c 100644 --- a/apps/cli-go/internal/functions/download/download_test.go +++ b/apps/cli-go/internal/functions/download/download_test.go @@ -308,6 +308,93 @@ func TestRunDockerUnbundle(t *testing.T) { }) } +// TestDownloadAllRejectsMaliciousSlug is a regression test for CLI-1891: the +// per-function loop in downloadAll must reject a path-traversal payload in +// a function's Slug -- as returned by V1ListAllFunctionsWithResponse, which +// this threat model treats as untrusted (a malicious/compromised Management +// API response, or a MITM) -- before handing it to any downloader that +// joins it into a filesystem path. +// +// This mirrors an exploit independently confirmed against downloadOne: with +// slug = "../../../../../poc-escaped-outside-project", downloadOne's +// eszipPath := filepath.Join(utils.TempDir, fmt.Sprintf("output_%s.eszip", +// slug)) resolves (after filepath.Clean) to "../poc-escaped-outside-project.eszip", +// i.e. one directory level above the project root, and +// afero.WriteReader happily MkdirAll's its way there and writes the +// server-controlled response body outside the sandbox. +func TestDownloadAllRejectsMaliciousSlug(t *testing.T) { + const maliciousSlug = "../../../../../poc-escaped-outside-project" + + // Use a real OS filesystem rooted at an isolated temp directory so an + // escape can actually be observed landing outside the project root, + // exactly as in the reviewer's PoC. + tmpDir := t.TempDir() + t.Chdir(tmpDir) + fsys := afero.NewOsFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + + project := apitest.RandomProjectRef() + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project + "/functions"). + Reply(http.StatusOK). + JSON([]api.FunctionResponse{{ + Id: "poc-id", + Name: "poc", + Slug: maliciousSlug, + }}) + // Mocked so that, if the fix is removed, the malicious slug's request + // still succeeds and downloadOne proceeds all the way to writing the + // escaped file -- proving the escape, rather than masking it behind an + // unrelated network error. With the fix in place this mock is never hit. + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project + "/functions/.*/body"). + Reply(http.StatusOK). + BodyString("fake eszip payload") + + // downloadOne is the exact sink the reviewer's PoC targeted directly; + // wrap it as a downloader so downloadAll's dispatch is exercised against + // the real vulnerable code, without also pulling in downloadWithDockerUnbundle's + // unrelated Docker extraction step (and its defer-cleanup of the eszip + // file, which would otherwise remove the escaped file before this test + // can observe it). + downloaderCalled := false + downloader := func(ctx context.Context, slug, projectRef string, fsys afero.Fs) error { + downloaderCalled = true + _, err := downloadOne(ctx, slug, projectRef, fsys) + return err + } + + err := downloadAll(context.Background(), project, fsys, downloader) + + // Check for the escape before any assertion below that could halt the + // test on failure (e.g. require.Error), so this is verified regardless + // of whether downloadAll happened to return an error for some other + // reason. t.TempDir()'s own cleanup RemoveAll's tmpDir's parent, which + // would otherwise sweep away the evidence once the test returns. + // + // The exploit resolves to "../poc-escaped-outside-project.eszip" + // relative to utils.TempDir, i.e. one level above the project root. + escapedPath := filepath.Join(tmpDir, "..", "poc-escaped-outside-project.eszip") + t.Cleanup(func() { _ = os.Remove(escapedPath) }) + exists, existsErr := afero.Exists(fsys, escapedPath) + require.NoError(t, existsErr) + assert.False(t, exists, "malicious slug must not be able to write outside the project directory") + + require.Error(t, err) + assert.ErrorIs(t, err, utils.ErrInvalidSlug) + assert.Contains(t, utils.CmdSuggestion, "unexpected function slug") + assert.False(t, downloaderCalled, "downloader must not be invoked with an unvalidated slug") + + assert.Empty(t, apitest.ListUnmatchedRequests()) +} + func TestRunServerSideUnbundle(t *testing.T) { const slug = "test-func" token := apitest.RandomAccessToken(t) @@ -453,6 +540,216 @@ func TestRunServerSideUnbundle(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) + + t.Run("rejects a path traversal payload in Supabase-Path", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + defer gock.OffAll() + mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ + {filename: "source/index.ts", contents: "console.log('hello')"}, + {filename: "leftover.ts", supabasePath: "../../../../../../etc/passwd", contents: "root:x:0:0::/root:/bin/bash"}, + }) + + err := Run(context.Background(), slug, project, false, false, fsys) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsafeDownloadPath) + + // The generic "invalid path in server response" message on its own + // gives users nothing actionable, so this must carry a specific + // suggestion (DX finding on CLI-1891) rather than falling through + // to the generic --debug suggestion. + assert.Contains(t, utils.CmdSuggestion, "malformed or unexpected API response") + + // Nothing should have escaped the functions directory. + exists, err := afero.Exists(fsys, "/etc/passwd") + require.NoError(t, err) + assert.False(t, exists, "path traversal payload must not be written outside the functions directory") + + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("writes deeply nested legitimate paths", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + + defer gock.OffAll() + mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ + {filename: "source/index.ts", contents: "console.log('hello')"}, + {filename: "source/a/b/c/d/deep.ts", contents: "export const deep = true;"}, + }) + + err := Run(context.Background(), slug, project, false, false, fsys) + require.NoError(t, err) + + root := filepath.Join(utils.FunctionsDir, slug) + data, err := afero.ReadFile(fsys, filepath.Join(root, "index.ts")) + require.NoError(t, err) + assert.Equal(t, "console.log('hello')", string(data)) + + data, err = afero.ReadFile(fsys, filepath.Join(root, "a", "b", "c", "d", "deep.ts")) + require.NoError(t, err) + assert.Equal(t, "export const deep = true;", string(data)) + + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("does not write through a pre-existing symlink at the destination", func(t *testing.T) { + // Symlink semantics only exist on a real filesystem, so this test + // exercises afero.NewOsFs against an isolated temp directory rather + // than the in-memory fs used elsewhere in this file. + tmpDir := t.TempDir() + t.Chdir(tmpDir) + fsys := afero.NewOsFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + + // A file living outside the sandboxed functions directory that must + // never be touched by the download. + outsideDir := filepath.Join(tmpDir, "outside") + require.NoError(t, os.MkdirAll(outsideDir, 0o755)) + secretPath := filepath.Join(outsideDir, "secret.txt") + require.NoError(t, os.WriteFile(secretPath, []byte("original"), 0o600)) + + // Plant a symlink inside the function directory, before the + // download runs, pointing at the file outside the sandbox. A + // server response that resolves to this exact path must not be + // allowed to write through it. + funcDir := filepath.Join(utils.FunctionsDir, slug) + require.NoError(t, os.MkdirAll(funcDir, 0o755)) + linkPath := filepath.Join(funcDir, "evil.ts") + require.NoError(t, os.Symlink(secretPath, linkPath)) + + defer gock.OffAll() + mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ + {filename: "source/index.ts", contents: "console.log('hello')"}, + {filename: "source/evil.ts", contents: "overwritten"}, + }) + + err := Run(context.Background(), slug, project, false, false, fsys) + require.NoError(t, err) + + // The symlink target outside the sandbox must be untouched. + data, err := os.ReadFile(secretPath) + require.NoError(t, err) + assert.Equal(t, "original", string(data), "file outside the functions directory must not be modified") + + // The destination itself should now be a plain file with the + // downloaded contents: the atomic rename replaces the symlink + // directory entry rather than following it. + info, err := os.Lstat(linkPath) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink, "planted symlink should have been replaced, not written through") + + data, err = os.ReadFile(linkPath) + require.NoError(t, err) + assert.Equal(t, "overwritten", string(data)) + + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + // Regression test for the DX finding on CLI-1891: ensureNoSymlinkInPath + // used to reject the mere presence of a symlink anywhere under + // utils.FunctionsDir, which would also have broken a legitimate + // monorepo pattern like this one, where a function directory + // symlinks in a shared directory that itself lives inside the + // functions tree. That symlink's target still resolves inside root, + // so it must be allowed end-to-end. + t.Run("writes through a symlink pointing to a legitimate location inside the functions directory", func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + fsys := afero.NewOsFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + + sharedDir := filepath.Join(utils.FunctionsDir, "_shared") + require.NoError(t, os.MkdirAll(sharedDir, 0o755)) + // os.Symlink resolves a relative target against the symlink's own + // containing directory, not the process cwd, so the target must be + // made absolute here for the link to actually point at sharedDir. + absSharedDir, err := filepath.Abs(sharedDir) + require.NoError(t, err) + + funcDir := filepath.Join(utils.FunctionsDir, slug) + require.NoError(t, os.MkdirAll(funcDir, 0o755)) + require.NoError(t, os.Symlink(absSharedDir, filepath.Join(funcDir, "_shared"))) + + defer gock.OffAll() + mockMultipartBody(t, project, slug, bundleMetadata{EntrypointPath: "source/index.ts"}, []multipartPart{ + {filename: "source/index.ts", contents: "console.log('hello')"}, + {filename: "source/_shared/util.ts", contents: "export const util = 2;"}, + }) + + err = Run(context.Background(), slug, project, false, false, fsys) + require.NoError(t, err) + + data, err := afero.ReadFile(fsys, filepath.Join(funcDir, "index.ts")) + require.NoError(t, err) + assert.Equal(t, "console.log('hello')", string(data)) + + // The write landed through the symlink, in the real shared + // directory rather than being rejected. + data, err = os.ReadFile(filepath.Join(sharedDir, "util.ts")) + require.NoError(t, err) + assert.Equal(t, "export const util = 2;", string(data)) + + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +// TestEnsureNoSymlinkInPath exercises ensureNoSymlinkInPath's resolve-then-check +// policy directly: it must still reject a symlink whose target escapes root, +// but must now allow one whose target resolves to a legitimate, even deeply +// nested, location that is still inside root. +func TestEnsureNoSymlinkInPath(t *testing.T) { + t.Run("rejects a symlink whose target resolves outside root", func(t *testing.T) { + tmpDir := t.TempDir() + fsys := afero.NewOsFs() + + root := filepath.Join(tmpDir, "supabase", "functions") + require.NoError(t, os.MkdirAll(root, 0o755)) + + outsideDir := filepath.Join(tmpDir, "outside") + require.NoError(t, os.MkdirAll(outsideDir, 0o755)) + + // Plant a symlink inside root whose target lives outside it + // entirely -- the actual attack this check defends against. + linkDir := filepath.Join(root, "escape") + require.NoError(t, os.Symlink(outsideDir, linkDir)) + + dir := filepath.Join(linkDir, "nested") + err := ensureNoSymlinkInPath(fsys, root, dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsafeDownloadPath) + }) + + t.Run("allows a symlink whose target resolves to a nested location inside root", func(t *testing.T) { + tmpDir := t.TempDir() + fsys := afero.NewOsFs() + + root := filepath.Join(tmpDir, "supabase", "functions") + require.NoError(t, os.MkdirAll(root, 0o755)) + + // A legitimate monorepo-style symlink: a shared directory that + // lives elsewhere inside the functions tree, symlinked into place + // from a function's own directory. + sharedDir := filepath.Join(root, "_shared") + require.NoError(t, os.MkdirAll(sharedDir, 0o755)) + + funcDir := filepath.Join(root, "my-func") + require.NoError(t, os.MkdirAll(funcDir, 0o755)) + linkDir := filepath.Join(funcDir, "_shared") + require.NoError(t, os.Symlink(sharedDir, linkDir)) + + // dir itself does not exist yet -- ensureNoSymlinkInPath is called + // before MkdirIfNotExistFS creates it -- so this also proves the + // still-nonexistent remainder is correctly re-joined onto the + // resolved ancestor. + dir := filepath.Join(linkDir, "nested", "deeper") + err := ensureNoSymlinkInPath(fsys, root, dir) + assert.NoError(t, err, "a symlink resolving to a legitimate location inside root must be allowed") + }) } func TestDownloadFunction(t *testing.T) { diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 23aa631e3b..6264016e79 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -128,6 +128,13 @@ type vectorConfig struct { DbId string } +func shouldMountRootDockerSocket(host string) bool { + return strings.HasSuffix(host, "/.docker/run/docker.sock") || + strings.HasSuffix(host, "/.docker/desktop/docker.sock") || + (strings.Contains(host, "/.colima/") && strings.HasSuffix(host, "/docker.sock")) || + strings.HasSuffix(host, "/.colima/docker.sock") +} + var ( //go:embed templates/vector.yaml vectorConfigEmbed string @@ -424,8 +431,7 @@ EOF case "unix": if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil { return errors.Errorf("failed to parse default host: %w", err) - } else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") || - strings.HasSuffix(parsed.Host, "/.docker/desktop/docker.sock") { + } else if shouldMountRootDockerSocket(parsed.Host) { // Docker will not mount rootless socket directly; // instead, specify root socket to have it handled under the hood binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host)) diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index 2977c5ed79..a69f7bd8bc 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -162,6 +162,20 @@ func TestStartCommand(t *testing.T) { }) } +func TestShouldMountRootDockerSocket(t *testing.T) { + t.Run("returns true for Docker Desktop and Colima sockets", func(t *testing.T) { + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/run/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.docker/desktop/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/default/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/local/docker.sock")) + assert.True(t, shouldMountRootDockerSocket("/Users/test/.colima/docker.sock")) + }) + + t.Run("returns false for directly mountable sockets", func(t *testing.T) { + assert.False(t, shouldMountRootDockerSocket("/Users/test/.orbstack/run/docker.sock")) + }) +} + func TestDatabaseStart(t *testing.T) { t.Run("starts database locally", func(t *testing.T) { // Setup in-memory fs diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index ec7e643391..6b9c671d1d 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,15 +5,15 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit FROM postgrest/postgrest:v14.14 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.07.06-sha-66cf431 AS studio +FROM supabase/studio:2026.07.07-sha-a6a04f2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.192.0 AS gotrue -FROM supabase/realtime:v2.112.6 AS realtime -FROM supabase/storage-api:v1.62.5 AS storage -FROM supabase/logflare:1.46.0 AS logflare +FROM supabase/realtime:v2.112.10 AS realtime +FROM supabase/storage-api:v1.66.2 AS storage +FROM supabase/logflare:1.47.0 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 3de3c61ae8..8afe2e0de5 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -26,7 +26,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.3 golang.org/x/mod v0.37.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 ) require ( @@ -51,8 +51,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index 8394b09a98..08ff799d66 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -217,8 +217,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -255,8 +255,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -290,8 +290,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c30465cd85..d4830ccc9e 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -90,14 +90,14 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: -| Path | What it provides | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | -| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → linked-project.json → config fallback chain; matches Go's resolver order | -| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | -| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `~/.supabase//linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | -| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | -| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | +| Path | What it provides | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | --- @@ -237,6 +237,10 @@ Concrete examples worth watching for as more commands land: This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +### `Config.Validate` parity has one home + +Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. + --- ## Legacy Port: Go CLI Output Parity @@ -281,12 +285,19 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go. - **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (grep `apps/cli-go/cmd/*.go`). Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). +- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. +- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. This gives two flag families their real value automatically, via the existing boolean-is-safe rule and a new choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: + - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. + - Choice globals: `--output`, `--dns-resolver`, `--agent`. + + Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted — matching Go, where `isEnumFlag` type-asserts `db diff`'s own non-enum local flag object instead of root's persistent `*utils.EnumFlag`. + - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: | Command | Event | Identity / groups | Go source | | ------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` + `analytics.identify(gotrueId)` after token persists | `internal/login/login.go:283-296` | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | | `sso/{list,create,update,remove}`, `branches/{create,update}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch | 7 call-sites under `internal/{sso,branches}/` | @@ -402,6 +413,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. ### Testing policy @@ -416,6 +428,21 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. +### Live tests (`*.live.test.ts`) + +Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. + +- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. +- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. +- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: + - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal, and there is no dedicated Docker-availability gate today. + - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). + - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). +- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. +- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). + --- ## Go CLI Parity Tracking diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 42d783057c..c1ac3b2c96 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -211,111 +211,111 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) - native; non-TypeScript project refs use pg-meta with IPv4 pooler retry instead of Go's "Try using --db-url" failure; `--swift-access-control` requires `--lang swift`; explicit source flags with `--query-timeout` on the remote TypeScript path error, while the implicit linked TypeScript path warns and continues | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: @@ -323,3 +323,18 @@ Flag divergences from the Go reference: `reveal=true` so the Management API returns the full secret keys (`sb_secret_...`) in full instead of redacting them, addressing issue #4775. Default behavior (omitted flag) matches Go exactly. +- `projects create` has a TS-only `--high-availability` flag (no Go equivalent). It sets + `high_availability` in the create request body. Default behavior (omitted flag) matches + Go exactly. + +Behavioral divergences from the Go reference: + +- `services` warns on a malformed linked project ref (matching Go's + `flags.LoadProjectRef` validation message) but, unlike Go, does not then use + that ref for the remote lookup. Go's `cmd/services.go` treats the validation + failure as non-fatal and still calls `listRemoteImages` with the malformed + value; TS skips the remote lookup instead, since the ref is embedded + unescaped into the tenant gateway hostname and a malformed value could + redirect the service-role key to an attacker-controlled host. Intentional + TS-only hardening, not a parity bug — see + [`services/SIDE_EFFECTS.md`](../src/legacy/commands/services/SIDE_EFFECTS.md). diff --git a/apps/cli/package.json b/apps/cli/package.json index c184c44d66..4d458e4441 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -43,9 +43,9 @@ "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.196", - "@anthropic-ai/sdk": "^0.107.0", - "@clack/prompts": "^1.6.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "@anthropic-ai/sdk": "^0.110.0", + "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/sql-pg": "catalog:", @@ -76,7 +76,7 @@ "oxlint-tsgolint": "catalog:", "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.38.8", + "posthog-node": "^5.39.4", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", diff --git a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md index a27b7f3ab6..9ab7ef9ade 100644 --- a/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/legacy/SIDE_EFFECTS_TEMPLATE.md @@ -61,7 +61,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index df39fcba27..8e69052093 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -37,6 +37,7 @@ function mockCliConfig(opts: { apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index d6625c88f7..15c124caa2 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -38,19 +38,18 @@ describe("legacy CLI agent output", () => { }); expect(exitCode).toBe(1); + // CLI-1901: the vendored effect CLI library's own duplicate JSON render + // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` + // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries + // exactly this repo's single Go-parity error line; the library's help + // doc is redirected to stderr instead of being dropped or duplicated. expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps parse errors in text mode when --output-format=text is explicit", async () => { @@ -63,7 +62,9 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + // CLI-1901: the help doc no longer prints to stdout at all. + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -77,7 +78,8 @@ describe("legacy CLI agent output", () => { ); expect(exitCode).toBe(1); - expect(stdout).toContain("DESCRIPTION"); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); }); @@ -92,18 +94,12 @@ describe("legacy CLI agent output", () => { expect(exitCode).toBe(1); expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ _tag: "Help" }), expect.objectContaining({ _tag: "Error", - error: expect.objectContaining({ code: "ShowHelp" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([ - expect.objectContaining({ - _tag: "Errors", - errors: [expect.objectContaining({ code: "UnknownSubcommand" })], + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }); test("keeps built-in version and help in text mode for detected coding agents", async () => { diff --git a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md index f69b0219d6..cff8a366c1 100644 --- a/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/list/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md index 8b08e2727b..768bb9b728 100644 --- a/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/backups/restore/SIDE_EFFECTS.md @@ -31,7 +31,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index a6428d8f47..2a28897a48 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -46,13 +46,13 @@ leaking the change to the surrounding process. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------- | -------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_API_URL`, `SUPABASE_PROFILE` | API host / profile | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------ | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | ## Exit Codes @@ -99,10 +99,11 @@ Human banners are suppressed; a single structured result is emitted: - **Interim Go-proxy delegation for migration push.** The push step shells out to the bundled Go binary (`db push --include-roles --include-seed`) until `db push` gets its own native port (separate Linear issue). The sub-step is **not** instrumentation-wrapped (the subprocess fires - its own push telemetry). Known divergence: `LegacyGoProxy.exec` propagates the exit code, so Go's - push backoff is **not** reproduced (single attempt) — to be restored when `db push` is natively - ported. (`LegacyGoProxy.exec` exits the process on a non-zero exit rather than returning a - failure, so the step cannot be wrapped in `Effect.retry`.) + its own push telemetry). Known divergence: Go's push backoff is **not** reproduced (single + attempt) — to be restored when `db push` is natively ported. (`LegacyGoProxy.exec` fails with a + typed `LegacyGoChildExitError` on a non-zero exit rather than exiting the process — CLI-1879 — so + the step COULD now be wrapped in `Effect.retry`; leaving that unimplemented here is a deliberate + scope decision for the native `db push` port, not a technical blocker.) - **DB password is forwarded on the same channel the user supplied it (CLI-1617).** The proxy must be called 1:1 with the user's input: a flag stays a flag, an env var stays an env var. So when the user passed `-p/--password`, the push sub-step receives `--password ` (flag → flag); when diff --git a/apps/cli/src/legacy/commands/branches/create/create.command.ts b/apps/cli/src/legacy/commands/branches/create/create.command.ts index 69367b3f4d..1cc85ecbce 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.command.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.command.ts @@ -44,7 +44,6 @@ const BRANCH_SIZES = [ "48xlarge_optimized_memory", "4xlarge", "8xlarge", - "nano", "small", "xlarge", ] as const; @@ -96,7 +95,7 @@ export const legacyBranchesCreateCommand = Command.make("create", config).pipe( Command.withShortDescription("Create a preview branch"), Command.withHandler((flags) => legacyBranchesCreate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index fcbb44aa0f..8fb2a9e5d5 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyBranchesCreateFlags } from "./create.command.ts"; +import { legacyBranchesCreateCommand, type LegacyBranchesCreateFlags } from "./create.command.ts"; import { legacyBranchesCreate } from "./create.handler.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; @@ -282,4 +284,44 @@ describe("legacy branches create integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`, reused by `branches create` + // at `apps/cli-go/cmd/branches.go:212`): Go's --size EnumFlag is an 18-value + // list that does not include "nano" (or "pico") and rejects any other value + // at flag-parse time. TS previously listed "nano" as a valid choice, silently + // succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyBranchesCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })(["create", "--size", "nano"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} diff --git a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md index 09bab3fd8f..4983406350 100644 --- a/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/branches/update/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Same auth and project-ref resolution chain as every Management-API legacy comman ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------ | +| `/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves — Go parity | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -51,4 +51,4 @@ In Go encoder modes, the header goes to stderr followed by the encoded payload o ## Notes -The upgrade-suggest call uses the parent project ref (resolved from `--project-ref`) rather than the branch's project ref. Both refs belong to the same organization, so the entitlement check returns the same `org_slug` either way; this also sidesteps a known API schema constraint where `getProject` strictly requires a `^[a-z]{20}$` ref. +The upgrade-suggest call uses the branch's own resolved project ref (`legacyResolveBranchProjectRef`), matching Go's `update.go:26` (`pause.GetBranchProjectRef`) — not the parent `--project-ref` value — so the entitlements check is scoped to the branch's org. diff --git a/apps/cli/src/legacy/commands/branches/update/update.command.ts b/apps/cli/src/legacy/commands/branches/update/update.command.ts index 205f2cdb28..2495a68601 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.command.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.command.ts @@ -52,7 +52,7 @@ export const legacyBranchesUpdateCommand = Command.make("update", config).pipe( Command.withShortDescription("Update a preview branch"), Command.withHandler((flags) => legacyBranchesUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md index f25912c9c5..ed1b76d1c2 100644 --- a/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/completion/SIDE_EFFECTS.md @@ -26,10 +26,10 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success — completion script for the chosen shell printed to stdout | -| `1` | invocation error (missing or unknown shell subcommand) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------- | +| `0` | success — completion script for the chosen shell printed to stdout | +| `1` | unknown shell subcommand, or bare `completion` with no shell subcommand — **known divergence, see Notes (CLI-1906)** | ## Output @@ -58,9 +58,19 @@ straight to the Go binary. - Effect CLI's `--completions` global flag remains exposed at the root for `next/` users; it does not satisfy the legacy parity contract and is not what this subcommand routes through. -- The Go CLI exits non-zero when called without a shell subcommand (e.g. - `supabase completion`). Effect CLI surfaces the same condition through its usual - "missing subcommand" help-with-exit-1 behavior. +- **Known divergence (CLI-1906):** Go's cobra CLI exits `0` on both bare + `completion` (no shell subcommand) AND `completion ` — cobra + treats an unrecognized subcommand name the same as a missing one: a + non-`Runnable()` command with no `RunE` returns `flag.ErrHelp`, which cobra + maps to printing help and returning a nil error (verified against the + compiled Go binary for both cases: `completion` and `completion +bogus-shell` both exit `0`). The legacy TS shell currently exits `1` for + both invocations; this is a real, systemic exit-code bug in the shared CLI + harness (`shared/cli/run.ts`), not `completion`-specific — it reproduces on + any bare or unrecognized-subcommand invocation of a group command with + subcommands (e.g. `branches`, `branches bogus-subcommand`). See CLI-1906 for + the fix; this doc describes current (buggy) behavior, not the intended + target. - Each of `bash`/`zsh`/`fish`/`powershell` declares `--no-descriptions` (cobra's auto-registered flag, `completions.go` in `spf13/cobra`) and forwards it to the Go binary, so the emitted script omits completion descriptions exactly as it diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 8a15ea687b..3661eb0d7e 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -7,20 +7,21 @@ local → if changed, print the unified diff and confirm → PATCH/PUT/POST. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | --------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | -| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | -| `~/.supabase//linked-project.json` | JSON | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | +| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per Go rules (see below) | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (mirrors Go's `ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism) | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | -| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | No writes to `config.toml`. @@ -50,14 +51,14 @@ when its local gate is off. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| Variable | Purpose | Required? | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | ## Exit Codes @@ -65,6 +66,7 @@ when its local gate is off. | ---- | ------------------------------------------------------------------------------------------ | | `0` | success, **including** declining a confirmation prompt (Go returns nil and continues) | | `1` | malformed `config.toml` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | | `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | list-addons failure (network or non-200) | @@ -115,5 +117,8 @@ keys mirror `config.toml` paths. - Diff bytes are byte-for-byte identical to the Go CLI (BurntSushi TOML encoder + anchored diff ports). - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) are decoded as defaulted-present by `@supabase/config`; their true presence is recovered from the raw (merged) config document so they are skipped when absent, matching Go's nil-pointer behaviour. - **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block declares `project_id == `, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — Go's `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`) — so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with Go's `duplicate project_id for [remotes.] and [remotes.]` message. +- **`encrypted:` (dotenvx) secrets are decrypted, hashed, and pushed as plaintext**, matching Go's `Secret.Decrypt`/`DecryptSecretHookFunc`. `DOTENV_PRIVATE_KEY`(`_*`) values from the shell + `supabase/.env` decrypt the ciphertext; the decrypted plaintext is what gets hashed for the diff and sent as `Secret.Value` in the update body. The ciphertext itself is never pushed. +- **An undecryptable secret aborts before any network call.** Before the cost-matrix list-addons request or any other service call, every `config.Secret`-typed value in the document is asserted decryptable — not just `auth.*` (the only fields `config push` actually sends), matching Go's document-wide decode hook, which runs the same check regardless of which fields a given command reads. This covers `[db.vault]` (a `map[string]Secret` in Go too, not just an `auth.*` field). An undecryptable value aborts with Go's `failed to parse config: ` message, exit code `1`. +- **Deprecated `[auth.external.{linkedin,slack}]` secrets are checked before they're stripped.** `@supabase/config` strips these deprecated blocks from the decoded document before returning it, but Go's decrypt hook runs at decode time — before its own later `external.validate()` deletes them — so `config push` re-checks the stripped-out sub-objects separately, matching Go's decode-before-delete order rather than missing a secret hiding in one of them. - KNOWN GAPS: - - **`encrypted:` (dotenvx) secret decryption is not reproduced.** The Go CLI decrypts `encrypted:` values before hashing and pushes the plaintext; we cannot decrypt here. Rather than push the ciphertext (which would overwrite the remote secret with garbage), `encrypted:` values are treated as unresolved — exactly like `env()` refs: they hash to `""`, so the empty hash gates them out of both the diff and the update body and the remote secret is left untouched. + - The document-wide decrypt-or-abort pre-check scans the config document `@supabase/config` hands back, which has the _matched_ `[remotes.]` block already merged in and its `remotes` key removed. An `encrypted:` secret that's undecryptable inside a **different, non-matching** `[remotes.*]` block is therefore not caught here (Go's decode hook runs over the whole parsed document, including every remote, so it would abort in that case too). Narrow edge case: it only matters when a project declares multiple remotes and the _unused_ one has a broken secret. diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 2321f9d960..75e6d9d484 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -16,7 +16,7 @@ import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.t import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; import type { AuthEmailContent } from "./config-sync.auth-email-content.ts"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; // --------------------------------------------------------------------------- // Sub-types @@ -858,9 +858,13 @@ function normalizeDurationStr(s: string | undefined): string { } } -function projectSecret(projectId: string, value: string | undefined): string { +function projectSecret( + projectId: string, + value: string | undefined, + dotenvPrivateKeys: ReadonlyArray, +): string { if (!value) return ""; - return secretHash(projectId, value); + return secretHash(projectId, value, dotenvPrivateKeys); } /** @@ -908,12 +912,21 @@ export interface AuthPresence { * Projects `config.auth` into the push subset, pre-computing all duration and * secret fields. `projectId` is the HMAC key for secret hashing. `presence` * carries raw-config presence for the optional sub-sections Go skips when nil. + * `dotenvPrivateKeys` (Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values) + * decrypt any `encrypted:` (dotenvx) secret before it's hashed or copied into + * {@link AuthSubset.rawSecrets} — see `config-sync.secret.ts`. + * + * @throws When an `encrypted:` secret cannot be decrypted with any key. The + * handler's document-wide pre-check (`legacyAssertDecryptableSecrets`) runs + * before this and is expected to have already aborted in that case, so this + * throw is a defensive backstop, not the primary abort path. */ export function authSubsetFromConfig( config: ProjectConfig, projectId: string, presence: AuthPresence, emailContent: AuthEmailContent = { template: {}, notification: {} }, + dotenvPrivateKeys: ReadonlyArray = [], ): AuthSubset { const a = config.auth; @@ -947,7 +960,7 @@ export function authSubsetFromConfig( : { enabled: captchaConfig.enabled ?? false, provider: captchaConfig.provider ?? "", - secret: projectSecret(projectId, captchaConfig.secret ?? ""), + secret: projectSecret(projectId, captchaConfig.secret ?? "", dotenvPrivateKeys), }; // Hooks — Go gates each on `hook. != nil`; absent in raw config → skip. @@ -960,7 +973,7 @@ export function authSubsetFromConfig( return { enabled: hc.enabled, uri: hc.uri ?? "", - secrets: projectSecret(projectId, hc.secrets ?? ""), + secrets: projectSecret(projectId, hc.secrets ?? "", dotenvPrivateKeys), }; } const hook: AuthSubset["hook"] = { @@ -1047,7 +1060,7 @@ export function authSubsetFromConfig( host: smtpConfig.host ?? "", port: smtpConfig.port ?? 0, user: smtpConfig.user ?? "", - pass: projectSecret(projectId, smtpConfig.pass ?? ""), + pass: projectSecret(projectId, smtpConfig.pass ?? "", dotenvPrivateKeys), admin_email: smtpConfig.admin_email ?? "", sender_name: smtpConfig.sender_name ?? "", }; @@ -1064,7 +1077,7 @@ export function authSubsetFromConfig( enabled: tc.enabled, account_sid: tc.account_sid ?? "", message_service_sid: tc.message_service_sid ?? "", - auth_token: projectSecret(projectId, tc.auth_token ?? ""), + auth_token: projectSecret(projectId, tc.auth_token ?? "", dotenvPrivateKeys), }; } const sms: AuthSubset["sms"] = { @@ -1077,18 +1090,18 @@ export function authSubsetFromConfig( messagebird: { enabled: s.messagebird.enabled, originator: s.messagebird.originator ?? "", - access_key: projectSecret(projectId, s.messagebird.access_key ?? ""), + access_key: projectSecret(projectId, s.messagebird.access_key ?? "", dotenvPrivateKeys), }, textlocal: { enabled: s.textlocal.enabled, sender: s.textlocal.sender ?? "", - api_key: projectSecret(projectId, s.textlocal.api_key ?? ""), + api_key: projectSecret(projectId, s.textlocal.api_key ?? "", dotenvPrivateKeys), }, vonage: { enabled: s.vonage.enabled, from: s.vonage.from ?? "", api_key: s.vonage.api_key ?? "", - api_secret: projectSecret(projectId, s.vonage.api_secret ?? ""), + api_secret: projectSecret(projectId, s.vonage.api_secret ?? "", dotenvPrivateKeys), }, test_otp: s.test_otp ?? {}, }; @@ -1105,7 +1118,7 @@ export function authSubsetFromConfig( external[k] = { enabled: p.enabled, client_id: p.client_id ?? "", - secret: projectSecret(projectId, p.secret ?? ""), + secret: projectSecret(projectId, p.secret ?? "", dotenvPrivateKeys), url: p.url ?? "", redirect_uri: p.redirect_uri ?? "", skip_nonce_check: p.skip_nonce_check ?? false, @@ -1193,33 +1206,52 @@ export function authSubsetFromConfig( allow_dynamic_registration: a.oauth_server.allow_dynamic_registration, authorization_url_path: a.oauth_server.authorization_url_path, }, - publishable_key: projectSecret(projectId, a.publishable_key ?? ""), - secret_key: projectSecret(projectId, a.secret_key ?? ""), - jwt_secret: projectSecret(projectId, a.jwt_secret ?? ""), - anon_key: projectSecret(projectId, a.anon_key ?? ""), - service_role_key: projectSecret(projectId, a.service_role_key ?? ""), + publishable_key: projectSecret(projectId, a.publishable_key ?? "", dotenvPrivateKeys), + secret_key: projectSecret(projectId, a.secret_key ?? "", dotenvPrivateKeys), + jwt_secret: projectSecret(projectId, a.jwt_secret ?? "", dotenvPrivateKeys), + anon_key: projectSecret(projectId, a.anon_key ?? "", dotenvPrivateKeys), + service_role_key: projectSecret(projectId, a.service_role_key ?? "", dotenvPrivateKeys), third_party, // Raw plaintext secrets for the update body (see AuthRawSecrets). Sourced - // from the same config accessors used for hashing above. + // from the same config accessors used for hashing above, decrypted the + // same way (`secretPlaintext`) so an `encrypted:` value is never sent + // as-is — Go always pushes `Secret.Value` (plaintext), never the ciphertext. rawSecrets: { - captcha: captchaConfig?.secret ?? "", + captcha: secretPlaintext(captchaConfig?.secret ?? "", dotenvPrivateKeys), hooks: { - mfa_verification_attempt: h.mfa_verification_attempt?.secrets ?? "", - password_verification_attempt: h.password_verification_attempt?.secrets ?? "", - custom_access_token: h.custom_access_token?.secrets ?? "", - send_sms: h.send_sms?.secrets ?? "", - send_email: h.send_email?.secrets ?? "", - before_user_created: h.before_user_created?.secrets ?? "", + mfa_verification_attempt: secretPlaintext( + h.mfa_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + password_verification_attempt: secretPlaintext( + h.password_verification_attempt?.secrets ?? "", + dotenvPrivateKeys, + ), + custom_access_token: secretPlaintext( + h.custom_access_token?.secrets ?? "", + dotenvPrivateKeys, + ), + send_sms: secretPlaintext(h.send_sms?.secrets ?? "", dotenvPrivateKeys), + send_email: secretPlaintext(h.send_email?.secrets ?? "", dotenvPrivateKeys), + before_user_created: secretPlaintext( + h.before_user_created?.secrets ?? "", + dotenvPrivateKeys, + ), }, - smtp_pass: smtpConfig?.pass ?? "", + smtp_pass: secretPlaintext(smtpConfig?.pass ?? "", dotenvPrivateKeys), sms: { - twilio: s.twilio.auth_token ?? "", - twilio_verify: s.twilio_verify.auth_token ?? "", - messagebird: s.messagebird.access_key ?? "", - textlocal: s.textlocal.api_key ?? "", - vonage: s.vonage.api_secret ?? "", + twilio: secretPlaintext(s.twilio.auth_token ?? "", dotenvPrivateKeys), + twilio_verify: secretPlaintext(s.twilio_verify.auth_token ?? "", dotenvPrivateKeys), + messagebird: secretPlaintext(s.messagebird.access_key ?? "", dotenvPrivateKeys), + textlocal: secretPlaintext(s.textlocal.api_key ?? "", dotenvPrivateKeys), + vonage: secretPlaintext(s.vonage.api_secret ?? "", dotenvPrivateKeys), }, - providers: Object.fromEntries(Object.entries(ext ?? {}).map(([k, p]) => [k, p.secret ?? ""])), + providers: Object.fromEntries( + Object.entries(ext ?? {}).map(([k, p]) => [ + k, + secretPlaintext(p.secret ?? "", dotenvPrivateKeys), + ]), + ), }, }; } diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts index 4083093f14..403cb01611 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts @@ -1418,10 +1418,11 @@ describe("authToUpdateBody secrets", () => { expect("security_captcha_secret" in body).toBe(false); }); - it("never pushes dotenvx ciphertext (encrypted: hashes to '' so the gate drops it)", () => { - // secretHash returns "" for `encrypted:` values, so the projected captcha - // secret is empty even though the raw (ciphertext) value is still present. - // The empty hash must gate the ciphertext out of the update body. + it("gates the raw secret out of the body when the hashed field is empty", () => { + // `authToUpdateBody` itself only knows the pre-computed hash/rawSecrets + // pair — this exercises the empty-hash gate directly (a decrypt failure + // for a real `encrypted:` value aborts earlier, in `authSubsetFromConfig` / + // the handler's document-wide pre-check; see push.integration.test.ts). const local = bareAuth({ enabled: true, captcha: { enabled: true, provider: "hcaptcha", secret: "" }, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts index 7b00046e6f..130bf6c48e 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.ts @@ -5,33 +5,72 @@ * Rules: * - Empty value → "" (no hash prefix). * - Value matching `^env\((.*)\)$` (unresolved env reference) → "" (no hash). - * - Value starting with `encrypted:` (dotenvx ciphertext) → "" (no hash). + * - Value starting with `encrypted:` (dotenvx ciphertext) → decrypt with + * `legacyDecryptSecret`, then hash the decrypted plaintext. * - Otherwise → "hash:" + sha256Hmac(projectId, value). * - * NOTE: `encrypted:` dotenvx decryption is not implemented. The Go CLI decrypts - * such values before hashing and pushes the plaintext; we cannot. Rather than - * hash and push the ciphertext — which would silently overwrite the remote - * secret with garbage — we treat `encrypted:` values as unresolved, exactly like - * `env()` refs: `secretHash` returns "", so the empty hash gates the value out of - * both the diff and the update body and the remote secret is left untouched. - * This is a documented residual gap for local dev use of dotenvx secrets - * (see SIDE_EFFECTS.md). + * `config push`'s handler runs a document-wide decrypt-or-abort pre-check + * (`push.handler.ts`, reusing `legacyAssertDecryptableSecrets`) immediately + * after loading `config.toml` and before any network call — mirroring Go's + * `config.Load` timing, where `DecryptSecretHookFunc` runs as part of the + * generic decode hook chain, before `internal/config/push.Run` ever reads the + * cost matrix or any service. By the time the functions below run (deep in + * the auth service's local/diff computation), decryption is therefore + * expected to always succeed. They still throw a `failed to parse config: + * ` error (Go's own wording) rather than silently gating the secret + * out, in case that invariant is ever violated by a future field addition. */ import { createHmac } from "node:crypto"; +import { legacyDecryptSecret } from "../../../../shared/legacy-vault-decrypt.ts"; + const ENV_PATTERN = /^env\((.*)\)$/; const ENCRYPTED_PREFIX = "encrypted:"; const HASHED_PREFIX = "hash:"; +/** Decrypts `value` when it's a dotenvx `encrypted:` ciphertext; otherwise returns it unchanged. */ +function decryptIfNeeded(value: string, dotenvPrivateKeys: ReadonlyArray): string { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); + if (!decrypted.ok) { + throw new Error(`failed to parse config: ${decrypted.error}`); + } + return decrypted.value; +} + /** * Returns the TOML serialisation of a Secret field, mirroring Go's - * `Secret.MarshalText`. The project ref is the HMAC key. + * `Secret.MarshalText`. The project ref is the HMAC key. `dotenvPrivateKeys` + * are Go's `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values + * (`legacyCollectDotenvPrivateKeys`), used to decrypt an `encrypted:` value + * before hashing — Go always hashes the decrypted plaintext, never the + * ciphertext. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. */ -export function secretHash(projectId: string, value: string): string { +export function secretHash( + projectId: string, + value: string, + dotenvPrivateKeys: ReadonlyArray, +): string { if (value.length === 0) return ""; if (ENV_PATTERN.test(value)) return ""; - if (value.startsWith(ENCRYPTED_PREFIX)) return ""; - const hmac = createHmac("sha256", projectId).update(value).digest("hex"); + const plaintext = decryptIfNeeded(value, dotenvPrivateKeys); + const hmac = createHmac("sha256", projectId).update(plaintext).digest("hex"); return HASHED_PREFIX + hmac; } + +/** + * Resolves a Secret field to the plaintext Go sends as `Secret.Value` in the + * update-request body — decrypting an `encrypted:` value with + * `dotenvPrivateKeys`, otherwise returning `value` unchanged. Callers gate on + * {@link secretHash}'s result being non-empty before using this (mirrors Go's + * `if len(Secret.SHA256) > 0`), so the empty/unresolved-`env()` cases never + * reach the request body regardless of what this returns for them. + * + * @throws When an `encrypted:` value cannot be decrypted with any key. + */ +export function secretPlaintext(value: string, dotenvPrivateKeys: ReadonlyArray): string { + return decryptIfNeeded(value, dotenvPrivateKeys); +} diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts index 9766f1efbb..707ad52f28 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.secret.unit.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for config-sync.secret.ts — golden parity with Go's - * `Secret.MarshalText` (`apps/cli-go/pkg/config/secret.go`). + * `Secret.MarshalText` + `DecryptSecretHookFunc` (`apps/cli-go/pkg/config/secret.go`). * * The HMAC keys/values below were captured from the same `createHmac` the port * uses; they lock the exact `hash:` serialisation Go emits. @@ -8,42 +8,86 @@ import { describe, expect, it } from "vitest"; -import { secretHash } from "./config-sync.secret.ts"; +import { secretHash, secretPlaintext } from "./config-sync.secret.ts"; + +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` uses). Decrypts to the plaintext "value". +const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; +const WRONG_KEY = "11".repeat(32); describe("secretHash", () => { it("returns the hash: form for a plaintext secret", () => { - expect(secretHash("abcdefghijklmnopqrst", "my-secret")).toBe( + expect(secretHash("abcdefghijklmnopqrst", "my-secret", [])).toBe( "hash:64800db722cc0be9e1d816d5aed626805e91a939d2dbcbc5239cd31eeef763e9", ); - expect(secretHash("test", "topsecret")).toBe( + expect(secretHash("test", "topsecret", [])).toBe( "hash:8eed2826599c798e072951884ced30954f8322fa1c3648506634e8376a740d72", ); }); it("keys the HMAC on the project ref (same value, different ref → different hash)", () => { - expect(secretHash("ref-a", "same")).not.toBe(secretHash("ref-b", "same")); + expect(secretHash("ref-a", "same", [])).not.toBe(secretHash("ref-b", "same", [])); }); it("returns '' for an empty value", () => { - expect(secretHash("abcdefghijklmnopqrst", "")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "", [])).toBe(""); }); it("returns '' for an unresolved env() reference", () => { - expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)")).toBe(""); - expect(secretHash("abcdefghijklmnopqrst", "env()")).toBe(""); - }); - - it("returns '' for a dotenvx encrypted: value (never hashes/pushes ciphertext)", () => { - // Regression: hashing/pushing the ciphertext would overwrite the remote - // secret with garbage. Treated as unresolved, like env(). - expect(secretHash("abcdefghijklmnopqrst", "encrypted:BvEYU1pXk9...")).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env(MY_SECRET)", [])).toBe(""); + expect(secretHash("abcdefghijklmnopqrst", "env()", [])).toBe(""); }); it("hashes a value that merely contains (but does not start with) 'encrypted:'", () => { // Only the dotenvx prefix is special; an embedded substring is a real secret. - expect(secretHash("test", "not-encrypted:value")).toBe( - secretHash("test", "not-encrypted:value"), + expect(secretHash("test", "not-encrypted:value", [])).toBe( + secretHash("test", "not-encrypted:value", []), + ); + expect(secretHash("test", "not-encrypted:value", []).startsWith("hash:")).toBe(true); + }); + + describe("dotenvx encrypted: values", () => { + it("decrypts before hashing (hash matches the decrypted plaintext, not the ciphertext)", () => { + expect(secretHash("abcdefghijklmnopqrst", ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe( + secretHash("abcdefghijklmnopqrst", "value", []), + ); + }); + + it("tries each key and the first working one wins", () => { + expect(secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY, PRIVATE_KEY])).toBe( + secretHash("test", "value", []), + ); + }); + + it("throws 'failed to parse config: missing private key' with no keys", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("throws 'failed to parse config: failed to decrypt secret: ...' for a wrong key", () => { + expect(() => secretHash("test", ENCRYPTED_VALUE, [WRONG_KEY])).toThrow( + /^failed to parse config: failed to decrypt secret:/, + ); + }); + }); +}); + +describe("secretPlaintext", () => { + it("returns a plain value unchanged", () => { + expect(secretPlaintext("my-secret", [])).toBe("my-secret"); + expect(secretPlaintext("", [])).toBe(""); + }); + + it("decrypts a dotenvx encrypted: value to its plaintext", () => { + expect(secretPlaintext(ENCRYPTED_VALUE, [PRIVATE_KEY])).toBe("value"); + }); + + it("never returns the ciphertext when decryption fails — throws instead", () => { + expect(() => secretPlaintext(ENCRYPTED_VALUE, [])).toThrow( + "failed to parse config: missing private key", ); - expect(secretHash("test", "not-encrypted:value").startsWith("hash:")).toBe(true); }); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ebd07baabc..e1fc8971f3 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -8,9 +8,13 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyAssertDecryptableSecrets, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { legacyCollectDotenvPrivateKeys } from "../../../shared/legacy-vault-decrypt.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -101,6 +105,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectRoot = (yield* findProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // dotenvx private keys for decrypting `encrypted:` secrets (Go's + // `DecryptSecretHookFunc`), from the shell + project env — same source/precedence + // as `legacy-db-config.toml-read.ts` (`process.env` wins over `supabase/.env`). + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that + // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. + // when this wider env source (matching Go's `loadNestedEnv`) resolves `VAR` but + // `@supabase/config`'s narrower one (`supabase/.env`/`.env.local` only) didn't. Practically + // unreachable in the same narrow way the CLI-1489 comment below already documents for + // non-secret fields; kept for parity with the shared function's other caller + // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). + const secretEnvLookup = (name: string): string | undefined => + process.env[name] ?? projectEnv[name]; const ref = yield* resolver.resolve(flags.projectRef); @@ -116,7 +133,10 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base config // before decode (Go's `loadFromFile` with `Config.ProjectId` set). A duplicate // `project_id` across remotes surfaces Go's verbatim message. - const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.catchTag( "ProjectConfigParseError", (cause) => @@ -141,6 +161,33 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const projectId = ref; const config = loaded.config; + // 1b. Assert every `config.Secret`-typed `encrypted:` value in the document + // (not just auth.*) can be decrypted, mirroring Go's global + // `DecryptSecretHookFunc`, which runs as part of `config.Load` — before + // `internal/config/push.Run` ever reads the cost matrix (`push.go:16` vs. + // `push.go:25`) or touches any service. An undecryptable secret anywhere in + // the document (even one `config push` never itself pushes, e.g. + // `studio.openai_api_key`) aborts here with Go's own `failed to parse + // config: ` message, before any remote service is read or updated. + // + // `loaded.document` has already had Go's deprecated `auth.external.{linkedin,slack}` + // blocks stripped by `@supabase/config` (`normalizeDeprecatedExternalProviders`), but + // Go's decrypt hook runs at decode time — before its own later `external.validate()` + // deletes those blocks — so an `encrypted:` secret hiding in one of them still aborts + // Go's load. Fold `removedDeprecatedExternalProviders` back into a synthetic + // `auth.external` view and scan that too, reusing the same path list rather than a + // second scanner. + const secretError = + legacyAssertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? + legacyAssertDecryptableSecrets( + { auth: { external: loaded.removedDeprecatedExternalProviders ?? {} } }, + secretEnvLookup, + dotenvPrivateKeys, + ); + if (secretError !== undefined) { + return yield* new LegacyConfigPushLoadConfigError({ message: secretError }); + } + // Optional `*pointer` sections (ssl_enforcement, image_transformation, // s3_protocol) are defaulted-present by @supabase/config and cannot be // recovered from the decoded config, so we inspect the raw (merged) document @@ -360,7 +407,19 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( }), ), ); - let local = authSubsetFromConfig(config, projectId, presence.auth, authEmailContent); + // `dotenvPrivateKeys` decrypts any `encrypted:` auth secret before it's + // hashed/copied into the update body. The document-wide check above + // (step 1b) already scanned every `config.Secret` path — including every + // field `authSubsetFromConfig` reads — and would have aborted by now if + // any were undecryptable, so the decrypt calls inside it are unreachable + // failure paths here, not a real branch to guard with `Effect.try`. + let local = authSubsetFromConfig( + config, + projectId, + presence.auth, + authEmailContent, + dotenvPrivateKeys, + ); const projected = applyRemoteAuthConfig(local, remote); // MFA phone/webauthn are paid addons: confirm cost before enabling. if (mfaPhoneNewlyEnabled(local, projected) && !(yield* keep("auth_mfa_phone"))) { diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 02ccd9d77c..0713bf7be0 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -29,6 +29,31 @@ function writeConfig(toml: string): void { writeFileSync(join(dir, "config.toml"), toml); } +// Go's test vector — `apps/cli-go/pkg/config/secret_test.go:9-19` (same one +// `legacy-vault-decrypt.unit.test.ts` and `config-sync.secret.unit.test.ts` use). +// Decrypts to the plaintext "value". +const DOTENVX_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const DOTENVX_ENCRYPTED_VALUE = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + +/** Save/restore `DOTENV_PRIVATE_KEY` around a test — mirrors the SUPABASE_YES pattern below. */ +function withDotenvPrivateKey( + value: string | undefined, + effect: Effect.Effect, +): Effect.Effect { + const prev = process.env["DOTENV_PRIVATE_KEY"]; + if (value === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = value; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; + else process.env["DOTENV_PRIVATE_KEY"] = prev; + }), + ), + ); +} + // Schema-valid PostgREST GET response with the api disabled remotely (empty // schema). The real API client validates GET bodies against the generated // output schema, so every postgrest GET must carry these fields. @@ -724,6 +749,172 @@ secret = "my-plaintext-secret" }, ); + it.live( + "decrypts a dotenvx encrypted: captcha secret and pushes the plaintext (CLI-1881)", + () => { + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = true +site_url = "http://localhost:3000" +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, apiMock } = setupService({ + toml, + yes: true, + v1: { + getAuthServiceConfig: () => Effect.succeed({}), + updateAuthServiceConfig: () => Effect.succeed({}), + }, + }); + return withDotenvPrivateKey( + DOTENVX_PRIVATE_KEY, + Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record; + // Go decrypts before hashing/pushing — the plaintext goes to the API, + // never the dotenvx ciphertext. + expect(input["security_captcha_secret"]).toBe("value"); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts before any network call when an encrypted: secret cannot be decrypted (CLI-1881)", + () => { + // `auth.enabled = false` on purpose: Go's decrypt hook runs for every + // `config.Secret` field during `config.Load`, before any feature gate is + // consulted — an undecryptable secret aborts even when the section that + // contains it would otherwise be skipped entirely. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + // The guard runs during config load, before any network call — not + // even the cost-matrix (list-addons) request that normally runs first. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live( + "aborts on an undecryptable secret config push never itself reads or pushes (CLI-1881)", + () => { + // `studio.openai_api_key` is a `config.Secret` field Go's `DecryptSecretHookFunc` + // still decrypts during `config.Load` — but no `config-sync/*.sync.ts` file (api, + // db, auth, storage, experimental) ever reads `studio.*`, so this proves the + // pre-check is genuinely document-wide, not merely reachable via `auth.*`. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[studio] +openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + + it.live("aborts on an undecryptable [db.vault] secret (CLI-1881)", () => { + // `db.vault` decodes as `map[string]Secret` in Go (`pkg/config/db.go:96`), so Go's + // decrypt hook covers it during `config.Load` — but the shared + // `legacyAssertDecryptableSecrets` path list used to omit `db.vault` on the theory + // that only the db-config reader's own downstream vault loop needed it. `config push` + // never runs that downstream loop, so this proves the shared path list now covers + // `db.vault` for every caller. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[db.vault] +my_secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }); + + it.live( + "aborts on an undecryptable secret in a deprecated [auth.external.slack] block (CLI-1881)", + () => { + // `@supabase/config` strips `auth.external.{linkedin,slack}` from `loaded.document` + // before returning it (`normalizeDeprecatedExternalProviders`), matching Go's + // `external.validate()` — but Go's decrypt hook runs at DECODE time, strictly before + // that later validate-time deletion, so a `secret` hiding in one of these deprecated + // blocks still aborts Go's load. This proves the pre-check folds + // `removedDeprecatedExternalProviders` back in rather than missing it. + const toml = `project_id = "test" +[storage] +enabled = false +[auth] +enabled = false +[auth.external.slack] +secret = "${DOTENVX_ENCRYPTED_VALUE}" +`; + const { layer, api } = setup({ toml, yes: true }); + return withDotenvPrivateKey( + undefined, + Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + }, + ); + it.live("pushes storage when enabled and changed", () => { const toml = `project_id = "test" [auth] diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts index 628b98db8b..2a9e55fc24 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts @@ -46,10 +46,10 @@ export const legacyDbAdvisorsCommand = Command.make("advisors", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // Go's changedFlagValues records every utils.EnumFlag verbatim - // (cmd/root_analytics.go:88-116). --db-url stays redacted (plain string, - // may carry secrets); --linked/--local are booleans (passed through). - safeFlags: ["type", "level", "fail-on"], + // type/level/fail-on are Flag.choice and are auto-detected as safe via + // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116); + // --db-url stays redacted (plain string, may carry secrets). + config, }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md index 5987e44efc..bd930c4242 100644 --- a/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/create/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------ | -| `/.branches//` (directory) | directory | always | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------ | +| `/supabase/.branches//` (directory) | directory | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md index 6b392e7110..97c31f4eb1 100644 --- a/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/delete/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | --------- | ------- | -| `/.branches//` (directory) | directory | removed | +| Path | Format | When | +| --------------------------------------------------------- | --------- | ------- | +| `/supabase/.branches//` (directory) | directory | removed | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md index 14e1803271..bbbe030daf 100644 --- a/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/list/SIDE_EFFECTS.md @@ -48,4 +48,4 @@ Not applicable. ## Notes - Deprecated in the Go CLI: use `branches list` instead. -- This is a local-only operation listing branches in `/.branches/`. +- This is a local-only operation listing branches in `/supabase/.branches/`. diff --git a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md index 5cce0f2423..aa1cd0cbf5 100644 --- a/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/branch/switch/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ------------------------------------ | ---------- | ------ | -| `/.supabase/current-branch` | plain text | always | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------ | +| `/supabase/.branches/_current_branch` | plain text | always | ## API Routes diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 0f72399b85..0a51f63b36 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -18,6 +18,7 @@ import { } from "../../../shared/legacy-connect-errors.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { cobraMutuallyExclusiveErrorMessage } from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import type { LegacyDbDumpFlags } from "./dump.command.ts"; import { @@ -42,15 +43,16 @@ import { /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined - * group keys alphabetically — `apps/cli-go/cmd/db.go:434,436,441,445`). The `key` - * preserves the registration order used in the error's `[group]`, while the set - * of violating flags is alphabetised in the message (cobra `sort.Strings(set)`). + * group keys alphabetically — `apps/cli-go/cmd/db.go:434,436,441,445`). Each + * group's flags are in registration order, matching cobra's `[group]` in the + * error text; the set of violating flags is alphabetised separately by + * `cobraMutuallyExclusiveErrorMessage` (cobra `sort.Strings(set)`). */ const LEGACY_DUMP_EXCLUSIVE_GROUPS = [ - { key: "db-url linked local", flags: ["db-url", "linked", "local"] }, - { key: "keep-comments data-only", flags: ["keep-comments", "data-only"] }, - { key: "role-only data-only", flags: ["role-only", "data-only"] }, - { key: "schema role-only", flags: ["schema", "role-only"] }, + ["db-url", "linked", "local"], + ["keep-comments", "data-only"], + ["role-only", "data-only"], + ["schema", "role-only"], ] as const; const DUMP_FILE_MODE = 0o644; @@ -126,11 +128,11 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy } }; for (const group of LEGACY_DUMP_EXCLUSIVE_GROUPS) { - const set = group.flags.filter(isSet); + const set = group.filter(isSet); if (set.length > 1) { return yield* Effect.fail( new LegacyDbDumpMutuallyExclusiveFlagsError({ - message: `if any flags in the group [${group.key}] are set none of the others can be; [${[...set].sort().join(" ")}] were all set`, + message: cobraMutuallyExclusiveErrorMessage(group, set), }), ); } diff --git a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md index f76f966c3c..49f2aac8ed 100644 --- a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md @@ -12,15 +12,18 @@ Native TypeScript port of Go's `internal/db/lint`. ## Files Written -| Path | Format | When | -| ---------------------------- | ------ | ---------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` only, via `LegacyLinkedProjectCache.cache` (Go's `ensureProjectGroupsCached`, `cmd/root.go:174,212-234`) | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush, Go `PersistentPostRun`) | No user data is written: the lint runs inside a transaction that is **always rolled back** (`BEGIN` … `ROLLBACK`), matching Go — including `CREATE EXTENSION plpgsql_check`, which is issued on the same connection inside -the open transaction and so is rolled back too. `db lint` does not write the -linked-project cache (it has no `LegacyLinkedProjectCache` dependency). +the open transaction and so is rolled back too. `db lint` DOES write the +linked-project cache when run with `--linked` (matching Go's +`ensureProjectGroupsCached`); the default `--local` and `--db-url` paths never +populate a project ref, so no cache write occurs there. ## API Routes diff --git a/apps/cli/src/legacy/commands/db/lint/lint.command.ts b/apps/cli/src/legacy/commands/db/lint/lint.command.ts index e97a625bc7..f754b6f42c 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.command.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.command.ts @@ -54,9 +54,10 @@ export const legacyDbLintCommand = Command.make("lint", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // Go records utils.EnumFlag values verbatim (cmd/root_analytics.go:88-116). + // level/fail-on are Flag.choice and are auto-detected as safe via + // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116). // --schema stays redacted: it's a []string slice flag in Go, not an EnumFlag. - safeFlags: ["level", "fail-on"], + config, // Go's changedFlags() uses pflag Visit, which reports the canonical // `schema` name even for the `-s` shorthand (cmd/db.go:506); map it so // `db lint -s public` records the schema flag in telemetry. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index d53964fab2..d024c5e789 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -84,6 +84,7 @@ export const legacyDbPullCommand = Command.make("pull", config).pipe( password: flags.password, }, aliases: { s: "schema", p: "password" }, + config, }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..5216459f76 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -1,59 +1,104 @@ # `supabase db push` +Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending +local migrations (and optionally seed data and custom roles) to the local or +linked/remote Postgres database. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list migration files to push | -| `/supabase/roles.sql` | SQL | when `--include-roles` is set | -| seed files from config | SQL | when `--include-seed` is set | +| Path | Format | When | +| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +No project files are written. All other effects are database mutations (below). + +## Database Mutations + +| Statement | When | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -### `--output-format text` (Go CLI compatible) +Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +two summary lines Go prints to **stdout** — ` is up to date.` and +`Finished supabase db push.` (the command name in Aqua) — go to stdout in text +mode; in machine modes they are suppressed and a structured result is emitted. -Prints applied migration versions to stderr. With `--dry-run`, prints the migrations that would be applied. +### `--output-format text` (Go CLI compatible) -### `--output-format json` +Byte-matches Go: connection status, per-item progress, prompts, and the stdout +summary line, including ANSI color (Aqua command name, Bold file paths). -Not applicable. +### `--output-format json` / `stream-json` -### `--output-format stream-json` +stdout is payload-only. A single `result` object is emitted: -Not applicable. +```json +{ + "upToDate": false, + "dryRun": false, + "migrations": [".sql"], + "seeds": ["supabase/seed.sql"], + "roles": ["supabase/roles.sql"] +} +``` ## Notes -- `--dry-run` prints the migrations that would be applied without applying them. -- `--include-all` includes all migrations not found in remote history table. -- `--include-roles` includes custom roles from the roles file. -- `--include-seed` includes seed data from config. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. +- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually + exclusive; with no flag the target defaults to linked, matching Go. +- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and + declining returns `context canceled`. +- **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. +- **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a + skip notice naming the project ref (empty for local/db-url). +- **Vault**: only non-empty, non-`env()` `[db.vault]` literals are synced (Go syncs + secrets with a non-empty SHA256). **Known gap vs Go**: `encrypted:`-prefixed + vault secrets are currently skipped — dotenvx/ECIES decryption is not yet ported. +- **Migrations catalog cache** (Go's best-effort `pgcache.TryCacheMigrationsCatalog`, + warning-only) is not ported; it produces no output, so parity is preserved. diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8c936315ca 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPush } from "./push.handler.ts"; +import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( @@ -37,5 +41,24 @@ export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( Command.withDescription("Push new migrations to the remote database."), Command.withShortDescription("Push new migrations to the remote database"), - Command.withHandler((flags) => legacyDbPush(flags)), + Command.withHandler((flags) => + legacyDbPush(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "include-all": flags.includeAll, + "include-roles": flags.includeRoles, + "include-seed": flags.includeSeed, + "dry-run": flags.dryRun, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPushRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts new file mode 100644 index 0000000000..3849d55add --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -0,0 +1,56 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:526`). + */ +export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Remote migration versions are missing from the local directory. Byte-matches + * Go's `migration.ErrMissingLocal` (`pkg/migration/apply.go:16`); the + * `migration repair` / `db pull` suggestion is attached (Go's `CmdSuggestion`). + */ +export class LegacyDbPushMissingLocalError extends Data.TaggedError( + "LegacyDbPushMissingLocalError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * Local migration files are ordered before the remote head and `--include-all` + * was not passed. Byte-matches Go's `migration.ErrMissingRemote` + * (`pkg/migration/apply.go:15`); the `--include-all` suggestion is attached. + */ +export class LegacyDbPushMissingRemoteError extends Data.TaggedError( + "LegacyDbPushMissingRemoteError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` + * (`internal/db/push/push.go:80,91,110`), rendered as `context canceled`. + */ +export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ + readonly message: string; +}> {} + +/** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ +export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ + readonly message: string; +}> {} + +/** + * A migration / seed / globals / vault statement failed while applying. Carries + * the underlying Postgres error (with Go's `At statement: ` context for + * migrations) so stderr matches Go's propagated error. + */ +export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..8bb4fdcd76 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -1,17 +1,340 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyCheckDbToml, + legacyLoadProjectEnv, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyApplyMigrations, + legacySeedGlobals, +} from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + LEGACY_ERR_MISSING_LOCAL, + LEGACY_ERR_MISSING_REMOTE, + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "../shared/legacy-migration-pending.ts"; +import { + type LegacySeedFile, + legacyGetPendingSeeds, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +// Listing the remote `schema_migrations` history (with the 42P01 → empty rule) +// lives in the shared migration-history module (Go's `migration.ListRemoteMigrations`). +import { legacyListRemoteMigrations } from "../../../shared/legacy-migration-history.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; +import { + LegacyDbPushApplyError, + LegacyDbPushCancelledError, + LegacyDbPushMissingLocalError, + LegacyDbPushMissingRemoteError, + LegacyDbPushRolesError, + LegacyDbPushTargetFlagsError, +} from "./push.errors.ts"; + +const CUSTOM_ROLES_PATH = "supabase/roles.sql"; + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ +const confirmPushAll = (filenames: ReadonlyArray): string => + filenames.map((name) => ` • ${legacyBold(name)}\n`).join(""); + +/** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ +const confirmSeedAll = (seeds: ReadonlyArray): string => + seeds + .map((seed) => ` • ${legacyBold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) + .join(""); + +const applyError = (message: string) => new LegacyDbPushApplyError({ message }); +/** + * `supabase db push` — apply pending local migrations (and optionally seed data + * and custom roles) to the local or linked/remote database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/push/push.go`. + */ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: LegacyDbPushFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "push"]; - if (flags.includeAll) args.push("--include-all"); - if (flags.includeRoles) args.push("--include-roles"); - if (flags.includeSeed) args.push("--include-seed"); - if (flags.dryRun) args.push("--dry-run"); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-`.env` + // key) before `PromptYesNo` reads `viper.GetBool("YES")`, so a `SUPABASE_YES` set only in + // `supabase/.env` auto-confirms. Resolve `yes` with that project env, as `db pull` does. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"), keyed off the + // explicitly-set flags (cobra's `Changed`), not the `--linked` default value. + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go's push defaults `--linked` to true, so no target flag → linked. + const connType = target.connType ?? "linked"; + + // The linked path resolves the project ref before loading config so a matching + // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For + // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. + let projectRef = ""; + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + projectRef = yield* refResolver.loadProjectRef(Option.none()); + linkedRefForCache = projectRef; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before connecting or writing) on any undecryptable/invalid config. + const toml = yield* legacyCheckDbToml( + fs, + path, + workdir, + projectRef !== "" ? projectRef : undefined, + ); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + if (flags.dryRun) { + yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + }); + const databaseName = cfg.isLocal ? "local database" : "remote database"; + const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + + // --- Collect pending migrations --- + let pending: ReadonlyArray = []; + if (!toml.migrationsEnabled) { + yield* output.raw( + `Skipping migrations because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const result = legacyFindPendingMigrations(local, remote); + if (result.kind === "missing-local") { + return yield* Effect.fail( + new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions), + }), + ); + } + if (result.kind === "missing-remote") { + if (!flags.includeAll) { + // Go's suggestIgnoreFlag lists the workdir-relative paths. + const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); + return yield* Effect.fail( + new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }), + ); + } + pending = legacyIncludeAllPending(local, remote.length, result.paths); + } else { + pending = result.pending; + } + } + + // --- Collect pending seeds --- + let seeds: ReadonlyArray = []; + if (flags.includeSeed) { + if (!toml.seed.enabled) { + yield* output.raw( + `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + seeds = yield* legacyGetPendingSeeds(session, fs, path, toml.seed.sqlPaths, workdir); + } + } + + // --- Collect custom roles --- + const globals: Array = []; + if (flags.includeRoles) { + const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPushRolesError({ + message: `failed to find custom roles: ${cause.message}`, + }), + ), + ); + if (exists) globals.push(CUSTOM_ROLES_PATH); + } + + // --- Nothing to push --- + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { + if (output.format === "text") { + yield* output.raw(`${statusTarget} is up to date.\n`); + } else { + yield* output.success(`${statusTarget} is up to date.`, { + upToDate: true, + dryRun: flags.dryRun, + migrations: [], + seeds: [], + roles: [], + }); + } + return; + } + + if (flags.dryRun) { + if (globals.length > 0) { + yield* output.raw( + `Would create custom roles ${legacyBold(globals[0]!)}...\n`, + "stderr", + ); + } + if (pending.length > 0) { + yield* output.raw("Would push these migrations:\n", "stderr"); + yield* output.raw(confirmPushAll(pending.map((p) => path.basename(p))), "stderr"); + } + if (seeds.length > 0) { + yield* output.raw("Would seed these files:\n", "stderr"); + yield* output.raw(confirmSeedAll(seeds), "stderr"); + } + } else { + // --- Custom roles --- + if (globals.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + "Do you want to create custom roles in the database cluster?", + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedGlobals( + session, + fs, + path, + globals.map((g) => path.join(workdir, g)), + applyError, + ); + } + + // --- Migrations --- + if (pending.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + // Go best-effort caches the migrations catalog for pg-delta; a failure + // only warns (`push.go:99-101`). The catalog cache is not yet ported, so + // there is nothing to warn about — parity is preserved (no extra output). + } else { + yield* output.raw("Schema migrations are up to date.\n", "stderr"); + } + + // --- Seeds --- + if (seeds.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } else if (flags.includeSeed) { + yield* output.raw("Seed files are up to date.\n", "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase db push")}.\n`); + } else { + yield* output.success("Finished supabase db push.", { + upToDate: false, + dryRun: flags.dryRun, + migrations: pending.map((p) => path.basename(p)), + seeds: seeds.map((s) => s.path), + roles: globals, + }); + } + }), + ); + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts new file mode 100644 index 0000000000..9a4b21db68 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -0,0 +1,826 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDbPush } from "./push.handler.ts"; +import type { LegacyDbPushFlags } from "./push.command.ts"; + +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbPushFlags = { + includeAll: false, + includeRoles: false, + includeSeed: false, + dryRun: false, + dbUrl: Option.none(), + linked: false, + local: true, + password: Option.none(), +}; + +function mockResolver(opts: { isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ + conn: LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; +}) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.suspend((): Effect.Effect => { + execs.push(sql); + if (opts.failExec !== undefined && sql === opts.failExec) { + return Effect.fail( + new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), + ); + } + return Effect.void; + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed( + (opts.remoteMigrations ?? []).map((version) => ({ version })), + ); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + yes?: boolean; + isLocal?: boolean; + projectRef?: string; + linkedFails?: boolean; + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => + opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + mockResolver({ isLocal: opts.isLocal ?? true }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + // Prompts (migration/seed confirmation) are answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here). + mockTty({ stdinIsTty: true }), + mockStdin(true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + projectRefLayer, + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, telemetry, linkedCache }; +} + +const MIGRATION_DIR = "supabase/migrations"; +const migrationFile = (version: string, body = "create table t ();") => ({ + [`${MIGRATION_DIR}/${version}_test.sql`]: body, +}); + +describe("legacy db push", () => { + const tmp = useLegacyTempWorkdir("supabase-db-push-"); + + it.live("reports up to date when nothing is pending (text)", () => { + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + // No migration was applied. + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("emits a json result for an up-to-date run", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(true); + expect(success?.data?.["migrations"]).toEqual([]); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("applies a pending migration after confirmation", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + // "supabase db push" is wrapped in Aqua (cyan) on stdout, matching Go. + expect(out.stdoutText).toContain("Finished"); + expect(out.stdoutText).toContain("supabase db push"); + // The migration body + history insert ran inside a transaction. + expect(conn.execs).toContain("BEGIN"); + expect(conn.execs).toContain("COMMIT"); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); + }); + }); + + it.live("returns context canceled when the migration prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("context canceled"); + } + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("prints the plan without applying in dry-run mode", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("DRY RUN: migrations will *not* be pushed to the database."); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("fails with a repair suggestion when remote has versions missing locally", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Remote migration versions not found in local migrations directory.", + ); + expect(JSON.stringify(exit.cause)).toContain("migration repair --status reverted"); + } + expect(out).toBeDefined(); + }); + }); + + it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + // 0101 is local-only and ordered before the already-applied remote 0202. + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--include-all"); + } + }); + }); + + it.live("pushes out-of-order migrations with --include-all", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("skips migrations when disabled in config and reports up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping migrations because it is disabled in config.toml for project:", + ); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("seeds a new file with --include-seed", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }); + }); + + it.live("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { + // Go's `GetPendingSeeds` (`Glob.SQLFiles`) walks a matched directory and seeds its + // regular `.sql` files recursively; non-.sql files are skipped. Without dir expansion + // the directory path reached `readFileString()` and failed. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["seeds"]\n', + files: { + "supabase/seeds/a.sql": "insert into t values (1);", + "supabase/seeds/nested/b.sql": "insert into t values (2);", + "supabase/seeds/notes.txt": "not a seed", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/a.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seeds/nested/b.sql..."); + expect(out.stderrText).not.toContain("notes.txt"); + }); + }); + + it.live("reports seed files up to date when hash matches remote", () => { + // sha256 of the seed body must match the remote hash to be skipped. + const body = "insert into t values (1);"; + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": body }, + remoteSeeds: { "supabase/seed.sql": hash }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { + // Go's `NewSeedFile` hashes the raw stream; a UTF-8 string decode would replace the + // invalid bytes and change the hash. Write invalid UTF-8 and pre-seed the remote with + // the RAW-byte sha256 — the push must treat it as already-applied (byte hash matches), + // not re-run it. A string-decoded hash here would differ and mark the seed dirty. + const raw = Buffer.from([0x2d, 0x2d, 0x20, 0xff, 0xfe, 0x00, 0x01, 0x0a]); + const rawHash = createHash("sha256").update(raw).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteSeeds: { "supabase/seed.sql": rawHash }, + }); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), raw); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("skips seeding when disabled in config", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping seed because it is disabled in config.toml for project:", + ); + }); + }); + + it.live("creates custom roles with --include-roles", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding globals from roles.sql..."); + }); + }); + + it.live("--include-roles without a roles.sql pushes migrations and skips globals", () => { + // Go's push only globs supabase/roles.sql when it exists; an absent file is + // silently skipped (no error, no "Seeding globals" line) and the rest pushes. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("Seeding globals"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("emits the seeded file paths in the json success payload", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(false); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + expect(success?.data?.["seeds"]).toEqual(["supabase/seed.sql"]); + }); + }); + + it.live("reports schema migrations up to date when only roles are pushed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Schema migrations are up to date."); + }); + }); + + it.live("returns context canceled when the roles prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("returns context canceled when the seed prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("re-hashes a dirty seed without re-running its statements", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + // Remote hash differs → dirty. + remoteSeeds: { "supabase/seed.sql": "stalehash" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); + // Dirty seed only upserts the hash; the body statement is not executed. + expect(conn.execs).not.toContain("insert into t values (1);"); + }); + }); + + it.live("treats every seed as pending when the seed_files table is absent", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + noSeedTable: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("warns and reports up to date when no seed files match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("reports seed files up to date when migrations push but no seeds match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed files are up to date."); + }); + }); + + it.live("upserts vault secrets (update existing, create new) before migrating", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + // `existing` already present remotely → update; `fresh` → create. + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + const sqls = conn.queries.map((q) => q.sql); + expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); + expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); + }); + }); + + it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { + // Regression: the old point-of-use vault decryption keyed only on `process.env`, so a + // `DOTENV_PRIVATE_KEY` present only in the project `.env` failed to decrypt. Go's config + // load merges the project `.env` into the key set (`legacyCheckDbToml`), so it resolves. + const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + const { layer, out, conn } = setup(tmp.current, { + toml: `project_id = "test"\n\n[db.vault]\nmy_secret = "${ENCRYPTED}"\n`, + files: { + ...migrationFile("20240101000000"), + "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + // The decrypted plaintext ("value") is written, proving the project-.env key was used. + const create = conn.queries.find((q) => q.sql === "SELECT vault.create_secret($1, $2)"); + expect(create?.params).toEqual(["value", "my_secret"]); + }); + }); + + it.live("defaults to the linked target when no target flag is set", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stdoutText).toBe("Remote database is up to date.\n"); + }); + }); + + it.live("surfaces an apply error with statement context", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "BOOM;"), + failExec: "BOOM", + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); + } + }); + }); + + it.live("dry-run lists roles, migrations and seeds without applying", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", + }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + dryRun: true, + includeRoles: true, + includeSeed: true, + }).pipe(Effect.provide(layer)); + // The roles path is wrapped in Bold (ANSI), matching Go's utils.Bold. + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).toContain("roles.sql"); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("Would seed these files:"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("dry-run with only custom roles lists them without a migration section", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, includeRoles: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).not.toContain("Would push these migrations:"); + }); + }); + + it.live("uses embedded defaults when no config file is present", () => { + const { layer, out } = setup(tmp.current, { + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + // No config.toml written → loadProjectConfig returns null → default config + // (migrations enabled), and the vault document is absent. + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("auto-confirms pending migrations via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before PromptYesNo reads viper YES, so a + // `SUPABASE_YES` in supabase/.env auto-confirms without any interactive answer. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("fails when config.toml cannot be parsed", () => { + const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message (the reader path), same as + // the other db commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config (no ProjectConfigParseError)", () => { + // Regression for the strict @supabase/config loader rejecting `enabled = "env(VAR)"`: + // Go decodes it via env-expansion + strconv.ParseBool, so the config must load and the + // migration proceed. Previously native push aborted before the Go-compatible parse ran. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("a matched remote block's migrations.enabled beats the shell env override", () => { + // Go merges a matched [remotes.] block at viper's override tier (`v.Set`), which + // sits ABOVE AutomaticEnv — so `[remotes.preview.db.migrations] enabled = false` wins + // over `SUPABASE_DB_MIGRATIONS_ENABLED=true` and the push skips migrations. (Before the + // config-reader convergence, push resolved this gate env-first and wrongly applied.) + const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n\n[remotes.preview.db.migrations]\nenabled = false\n`, + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Skipping migrations because it is disabled"); + expect(out.stderrText).not.toContain("Applying migration 20240101000000"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; + else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("announces a matching [remotes.*] override on the linked path", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("pushes to the linked project and caches the project ref (json)", () => { + const { layer, out, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts new file mode 100644 index 0000000000..dbc5bd5250 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -0,0 +1,78 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local + * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * connection, the db-config resolver, project-ref resolution, and the + * linked-project cache (Go's PersistentPostRun `ensureProjectGroupsCached`). + * + * Like `db lint`, it deliberately uses the **lazy** `legacyPlatformApiFactoryLayer` + * (not the eager management-API runtime) so the auth-free `--local` path never + * resolves an access token at layer-build time. `legacyCliConfigLayer` is provided + * to each consumer that needs it (legacy CLAUDE.md item 5); the single + * `legacyIdentityStitchLayer` reference is shared so the factory, the cache, and + * the db-config resolver share one `stitchAttempted` guard. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbPushRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped `db push` that reaches a confirmation + // prompt fails with a missing-service defect instead of honoring `y`/`n` or the default. + stdinLayer, + commandRuntimeLayer(["db", "push"]), +); diff --git a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md index 2b2e6ad3d2..88785a5530 100644 --- a/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/remote/commit/SIDE_EFFECTS.md @@ -37,7 +37,10 @@ ### `--output-format text` (Go CLI compatible) -Prints `Finished supabase db pull.` on success. +Prints `Schema written to ` to stderr on success (from the shared +`pull.Run`, `internal/db/pull/pull.go:72`); no stdout confirmation message is +printed. The `Finished supabase db pull.` PostRun message belongs only to +`db pull` (`cmd/db.go:198-200`), not `db remote commit`. ### `--output-format json` diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index d1291e27a4..0934154974 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,60 +1,148 @@ # `supabase db reset` +Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a +database from local migrations (plus seed). The **remote** path (`--linked`, or a +remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then +re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` +pointing at the local stack) is also native: TS orchestrates the running check, +messages, bucket seeding, and git-branch line, while the container-recreate +primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche +**`--experimental`** remote schema-files path still delegates to the Go binary. + ## Files Read -| Path | Format | When | -| --------------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config or `--sql-paths` | SQL | unless `--no-seed` is set | +| Path | Format | When | +| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +On the local path the Go seam additionally recreates the `supabase_db_` +container/volume and applies the initial schema (`SetupLocalDatabase`); the +`--experimental` remote path produces whatever the delegated Go binary writes. + +## Subprocesses + +| Command | When | Purpose | +| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | +| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | + +The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; +`--network-id` / a flag-selected `--profile` are forwarded. + +## Database Mutations + +### Remote path (native, in TS) + +| Statement | When | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | + +### Local path (inside the Go seam) + +The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or +removes & recreates the db container/volume (PG15), applies the initial schema + +roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) +and restarts the storage/auth/realtime/pooler containers. Bucket objects are then +seeded over the Storage gateway (reusing the `seed buckets` local path). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| child's exact code\* | local: container recreate / storage health-gate failure (seam), or `--experimental`/`--linked` delegate (proxy) child exit | + +\* The `db __db-bootstrap` seam and the `--experimental` remote delegate both +propagate the spawned `supabase-go` child's real exit code (e.g. `130` after a +Ctrl-C mid-recreate) instead of collapsing every failure to `1` — in every +`--output-format` (CLI-1879). ## Output +The remote path prints `Resetting remote database…` to **stderr**, then the +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go +connects with `io.Discard`, so there is **no** `Connecting to … database…` line and +**no** `Finished …` line on the remote path. + +The local path prints `Resetting local database…` to **stderr**, then the seam's +`Recreating database...` / `Restarting containers...` progress, and finally +`Finished supabase db reset on branch .` (`supabase db reset` and `` +in Aqua). + ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as migrations are applied. +Byte-matches Go's stderr progress for both the remote and local paths. The +`--experimental` remote path passes the delegated Go binary's output through +unchanged. -### `--output-format json` +### `--output-format json` / `stream-json` -Not applicable. +stdout is payload-only; a `result` object is emitted: -### `--output-format stream-json` +```json +{ "target": "remote" | "local", "version": "" } +``` -Not applicable. +In machine modes the remote confirmation prompt is non-interactive and takes its +default (`false`), so a remote reset is declined unless `--yes` is set. The local +path has no confirmation prompt. ## Notes -- `--no-seed` skips running the seed script after reset. -- `--sql-paths` overrides `[db.seed].sql_paths` for one reset; repeat it to seed multiple files or glob patterns. -- `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. -- With `--linked` or `--db-url`, `--sql-paths` seeds the selected remote database after migrations. -- `--version` resets up to the specified migration version. -- `--last` resets up to the last n migration versions; mutually exclusive with `--version`. +- **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the + flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the + local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset and force-enables seeding + even when `[db.seed].enabled = false`; repeat it to seed multiple files or glob + patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path + it is forwarded to the recreate seam; on the remote path it seeds the selected + database after migrations (Go warns when paired with `--linked` / `--db-url`). +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target + version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **Known interim**: only `--experimental` remote resets run via the Go binary; the + best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output + impact). `encrypted:` vault secrets are skipped on the remote path. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 15a61c8d88..979d088f84 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbReset } from "./reset.handler.ts"; +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; const noSqlPaths: ReadonlyArray = []; @@ -42,5 +46,24 @@ export type LegacyDbResetFlags = CliCommand.Command.Config.Infer; export const legacyDbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), - Command.withHandler((flags) => legacyDbReset(flags)), + Command.withHandler((flags) => + legacyDbReset(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "no-seed": flags.noSeed, + "sql-paths": flags.sqlPaths, + version: flags.version, + last: flags.last, + }, + // NO safeFlags: `markFlagTelemetrySafe` is per flag INSTANCE, and Go only + // marks migration squash's `--version` (cmd/migration.go:134). db reset's + // `--version` (cmd/db.go) is unmarked, so Go redacts it — match that. + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbResetRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts new file mode 100644 index 0000000000..bfb4c3e539 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -0,0 +1,88 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). + */ +export class LegacyDbResetTargetFlagsError extends Data.TaggedError( + "LegacyDbResetTargetFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` and `--last` together. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("version", "last")` (`cmd/db.go:576`). + */ +export class LegacyDbResetVersionFlagsError extends Data.TaggedError( + "LegacyDbResetVersionFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` is not a valid integer. Byte-matches Go's + * `failed to parse : invalid version number` (`repair.go:24-29`). + */ +export class LegacyDbResetInvalidVersionError extends Data.TaggedError( + "LegacyDbResetInvalidVersionError", +)<{ + readonly message: string; +}> {} + +/** + * No migration file matches `--version`. Byte-matches Go's + * `glob supabase/migrations/_*.sql: file does not exist` + * (`repair.GetMigrationFile`). + */ +export class LegacyDbResetMigrationFileError extends Data.TaggedError( + "LegacyDbResetMigrationFileError", +)<{ + readonly message: string; +}> {} + +/** + * The user declined the reset confirmation. Go returns + * `errors.New(context.Canceled)` (`internal/db/reset/reset.go:248`). + */ +export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ + readonly message: string; +}> {} + +/** A drop / migrate / seed / vault statement failed during the remote reset. */ +export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ + readonly message: string; +}> {} + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). + */ +export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ + readonly message: string; +}> {} + +/** + * `--last` was given a negative value. Go declares `--last` as an unsigned flag + * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches + * cobra's parse error for `strconv.ParseUint`. + */ +export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ + readonly message: string; +}> {} + +/** + * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` + * (`cmd/db.go`): `"--no-seed cannot be used with --sql-paths"` and + * `"--sql-paths requires a non-empty path or glob pattern"`. + */ +export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbResetSeedFlagsError")<{ + readonly message: string; + /** + * Actionable hint rendered as a `Suggestion:` line, mirroring Go's + * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index de35923ac2..4b5fc92782 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,16 +1,480 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyCheckDbToml, + legacyLoadProjectEnv, + legacyResolveSeedSqlPath, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { + type LegacyDbConnType, + resolveLegacyDbTargetFlags, +} from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; +import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; +import { + LegacyDbResetApplyError, + LegacyDbResetCancelledError, + LegacyDbResetInvalidVersionError, + LegacyDbResetLastFlagError, + LegacyDbResetMigrationFileError, + LegacyDbResetNotRunningError, + LegacyDbResetSeedFlagsError, + LegacyDbResetTargetFlagsError, + LegacyDbResetVersionFlagsError, +} from "./reset.errors.ts"; -export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "reset"]; +const INTEGER_PATTERN = /^[+-]?\d+$/u; +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +const applyError = (message: string) => new LegacyDbResetApplyError({ message }); + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +/** + * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote + * `--experimental` reset with no resolved version. Only the flags reachable on + * that path are forwarded — `--local` always takes the native path, and a set + * `--version`/`--last` resolves a non-empty version which disables the experimental + * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical + * whether or not it is forwarded, so it is omitted). + * + * The target selector is forwarded from the RESOLVED `connType`, not the raw `--linked` + * boolean: the parent's `resolveLegacyDbTargetFlags` follows Cobra's `Changed` semantics, so + * `--linked=false` selects the linked/remote target (this path is remote-only). Forwarding + * only when `flags.linked === true` would drop the selector for `--linked=false` and let the + * Go child fall back to its local default — resetting the wrong database. + */ +const buildResetArgs = ( + flags: LegacyDbResetFlags, + connType: LegacyDbConnType, + yes: boolean, +): Array => { + const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); + else if (connType === "linked") args.push("--linked"); if (flags.noSeed) args.push("--no-seed"); - for (const path of flags.sqlPaths) args.push("--sql-paths", path); - if (Option.isSome(flags.version)) args.push("--version", flags.version.value); - if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); - yield* proxy.exec(args); + for (const p of flags.sqlPaths) args.push("--sql-paths", p); + // Forward the parent's RESOLVED `yes` as a bound flag. Go's `--yes` beats `AutomaticEnv`, + // so `--yes=false` overrides an inherited `SUPABASE_YES=true` (the child no longer + // auto-confirms a reset the user protected with `--yes=false`), while `--yes=true` honors + // an explicit `--yes` / env even in machine mode where the child's stdin is ignored. + // `--yes=false` still prompts on a TTY (Go's PromptYesNo only short-circuits on true), so + // this matches the default behavior when neither flag nor env is set. + args.push(`--yes=${yes}`); + return args; +}; + +/** + * `supabase db reset` — reinitialise a database from local migrations (+ seed). + * + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path + * (`--linked` / a remote `--db-url`) is native. The local path (and the niche + * `--experimental` schema-files path) delegate to the Go binary as a documented + * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + */ +export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const proxy = yield* LegacyGoProxy; + const seam = yield* LegacyDbBootstrapSeam; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + + const workdir = cliConfig.workdir; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) + // before `reset.Run` reads `viper.GetBool("YES")` / `viper.GetBool("EXPERIMENTAL")`, so a + // `SUPABASE_YES` / `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is honored. Load the + // project env first and resolve both gates against it, as `db pull` does for `yes`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go declares `--last` as `UintVar`, so cobra rejects a negative at parse time + // (`Flag.integer` here accepts it). Reject it the same way rather than silently + // treating it as "no --last" and resetting the full history. + if (Option.isSome(flags.last) && flags.last.value < 0) { + return yield* Effect.fail( + new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }), + ); + } + // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. + if (Option.isSome(flags.version) && Option.isSome(flags.last)) { + return yield* Effect.fail( + new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }), + ); + } + + // Go's validateDbResetSeedFlags (PreRunE): `--no-seed` conflicts with + // `--sql-paths`, and each `--sql-paths` value must be non-empty. + if (flags.noSeed && flags.sqlPaths.length > 0) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--no-seed cannot be used with --sql-paths", + suggestion: `Use either ${legacyAqua("--no-seed")} to skip seeding or ${legacyAqua( + "--sql-paths", + )} to override seed files, not both.`, + }), + ); + } + if (flags.sqlPaths.some((p) => p.length === 0)) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--sql-paths requires a non-empty path or glob pattern", + suggestion: `Pass a non-empty file path or glob pattern to ${legacyAqua("--sql-paths")}.`, + }), + ); + } + // Go's warnRemoteResetSeedOverride (PreRunE): a remote target flag + --sql-paths. + if ( + flags.sqlPaths.length > 0 && + (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) + ) { + yield* output.raw( + `${legacyYellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.\n`, + "stderr", + ); + } + + // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. + let resolvedVersion = ""; + if (Option.isSome(flags.version)) { + const v = flags.version.value; + if (!INTEGER_PATTERN.test(v)) { + return yield* Effect.fail( + new LegacyDbResetInvalidVersionError({ + message: `failed to parse ${v}: invalid version number`, + }), + ); + } + // Go validates the version with `repair.GetMigrationFile` (repair.go:90-100), + // which globs `supabase/migrations/_*.sql` DIRECTLY with no filtering — + // so a deprecated first migration (e.g. `20200101000000_init.sql`) that + // `legacyListLocalMigrations` excludes is still accepted. Mirror that with a raw + // directory read + Go-glob match instead of the filtered migration listing. + const entries = yield* fs + .readDirectory(migrationsDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = entries.some( + (name) => legacyPathMatch(`${v}_*.sql`, path.basename(name)).matched, + ); + if (!found) { + return yield* Effect.fail( + new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }), + ); + } + resolvedVersion = v; + } else if (Option.isSome(flags.last) && flags.last.value > 0) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const versions = locals.flatMap((p) => { + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined ? [m[1]] : []; + }); + const total = versions.length; + const last = flags.last.value; + resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + } + + const connType = target.connType ?? "local"; + // Single source of truth for "does this reset delegate to the Go child?" — + // checked at both delegation sites below (before `resolve()` for a linked + // target, after it for a `--db-url` target) so the two call sites can never + // drift apart. + const shouldDelegateExperimental = experimental && resolvedVersion === ""; + + // Delegates the remaining `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`, not ported) to the Go child. In text mode inherit + // its stdio. Under a machine-output mode (`--output-format json|stream-json`) + // the Go child emits no TS envelope, so suppress its stdout (capture + discard) + // and emit the same structured success the native local and remote paths do, + // keeping the JSON contract consistent across all reset paths. + const delegateExperimentalReset = () => + Effect.gen(function* () { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format === "text") { + yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); + } else { + // Machine-output mode is non-interactive: give the Go child a non-TTY stdin + // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's + // destructive reset prompt — it takes the default `false`, matching the + // native reset path which suppresses prompts under json/stream-json. + yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + // Go's ParseDatabaseConfig runs LoadProjectRef BEFORE the fallible linked + // resolution (db_url.go:87-95), and Execute() writes the linked-project cache + // even when a later step errors (root.go:171-181). Pre-load the ref so the + // post-run cache finalizer still fires when resolve fails mid-way (merged + // config, temp-role mint, connection) — mirrors push.handler. + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + + // A linked target is never local (`resolver.resolve()`'s "linked" branch + // always returns `isLocal: false`), so the delegated-experimental check can + // run BEFORE calling `resolve()`. This matters: for `connType === "linked"`, + // `resolve()` mints/verifies a temporary Postgres login role over the + // Management API — and the delegated Go child re-runs that exact same + // `ParseDatabaseConfig` work itself once delegation happens. Calling + // `resolve()` here would mint the temp role twice for zero downstream use on + // this branch (Go's own reset flow mints it exactly once, as part of the code + // path being delegated to — confirmed against `apps/cli-go/internal/utils/ + // flags/db_url.go`'s `NewDbConfigWithPassword`/`initLoginRole`). CLI-1879. + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); + return; + } + } + + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + + // Local target → native local reset. The container-recreate primitives live + // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest + // (running check, messages, bucket seeding, git-branch line, output shaping). + // Mirrors `internal/db/reset/reset.go:57-77`. + if (cfg.isLocal) { + // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's + // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full + // config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning` + // / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The + // resolver's own local read (above, line 239) already performs the identical + // validation and would already reject a broken config before this point is + // reached — so today this re-validates for its own sake. Repeat it here anyway, + // as an explicit, independent gate (the same pattern `db start` and `db push` + // use), so the "malformed config aborts before the local database is recreated" + // guarantee is enforced by this handler directly and stays covered by a + // handler-level test even if the resolver's own internal read is ever mocked, + // relaxed, or refactored to stop validating. + yield* legacyCheckDbToml(fs, path, workdir); + + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* seam.isDbRunning(); + if (!running) { + return yield* Effect.fail( + new LegacyDbResetNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); + yield* seam.recreateDatabase({ + version: resolvedVersion, + noSeed: flags.noSeed, + sqlPaths: flags.sqlPaths, + }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* seam.awaitStorageReady(); + if (storageReady) { + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + // + // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, + // which mirrors Go's full nested-env walk (`.env..local`, + // `.env.local`, `.env.`, `.env`, across both `supabase/` and the + // project root — `pkg/config/config.go:1220-1257`). This reload instead goes + // through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, + // which only ever reads `supabase/.env`/`.env.local` plus ambient env + // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, + // which only widens `env(VAR)` matching, not the file set consulted. So a + // config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development` + // is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is + // real ambient env by the time Go resolves it — `config.go:1260-1261`) and + // already passed `legacyCheckDbToml` and the real recreate above, but this + // narrower reload can still reject it. A `LegacySeedConfigLoadError` here is + // that env-file-set gap, not a genuinely invalid config — and recreate already + // dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn + // and skip buckets so `db reset` finishes like Go instead. + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` + // auto-confirms bucket/vector/analytics prune prompts. Pass the project-env-resolved + // `yes` (the shared runner's own `legacyResolveYes` only sees the shell env). + yes, + }).pipe( + Effect.catchTag("LegacySeedConfigLoadError", (error) => + output.raw( + `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Reset local database.", { + target: "local", + version: resolvedVersion, + }); + } + return; + } + + // Re-confirm `linkedRefForCache` from the now-resolved `cfg.ref` for the native + // remote linked path below (a linked+experimental+versionless target already + // delegated and returned above, before `resolve()` was ever called — see the + // `connType === "linked"` block earlier in this function). A `connType === + // "db-url"` target leaves `linkedRefForCache` as whatever the pre-load block + // set (nothing, for `db-url`), since this assignment only fires when linked. + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + + // Remaining remote target: a `--db-url` pointing at a non-local host (the + // `connType === "linked"` case already delegated above, before `resolve()`, + // without resolving a connection at all). + if (shouldDelegateExperimental) { + yield* delegateExperimentalReset(); + return; + } + + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing + // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every + // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // aborting here (before the destructive prompt / `legacyDropUserSchemas`) on any + // undecryptable/invalid config, exactly like Go's `LoadConfig` before ResetAll. + const configRef = connType === "linked" && linkedRef !== undefined ? linkedRef : undefined; + const toml = yield* legacyCheckDbToml(fs, path, workdir, configRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + const vaultSecrets = toml.vault; + + // Go's resetRemote: prompt (default false) → cancel, then ResetAll. + const shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Do you want to reset the remote database?", + false, + ); + if (!shouldReset) { + return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + } + yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); + + // Go connects with io.Discard, so NO "Connecting to ... database..." line. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); + // ResetAll: drop user schemas → upsert vault → migrate + seed. + yield* legacyDropUserSchemas(session, applyError); + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + + if (toml.migrationsEnabled) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + // LoadPartialMigrations filter: version === "" || v <= version. + const pending = locals.filter((p) => { + if (resolvedVersion === "") return true; + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined && m[1] <= resolvedVersion; + }); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + } + + // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths + // and force-enables it (Go's applyDbResetSeedFlags). The two are mutually + // exclusive (validated above). + const overrideSeed = flags.sqlPaths.length > 0; + // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise + // honor `db.seed.enabled` (already `SUPABASE_DB_SEED_ENABLED`-resolved by the reader). + const seedEnabled = overrideSeed || (toml.seed.enabled && !flags.noSeed); + if (seedEnabled) { + // `[db.seed].sql_paths` is already Go-config-resolved (supabase/-joined) by the + // reader; the `--sql-paths` override is resolved here the same way Go's + // `resolveSeedSqlPaths` does, so both feed the glob identical paths. + const seedPaths = overrideSeed + ? flags.sqlPaths.map((p) => legacyResolveSeedSqlPath(path, p)) + : toml.seed.sqlPaths; + const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } + // Go's best-effort pgcache catalog warning is not ported (no output impact). + }), + ); + + if (output.format !== "text") { + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index bd533d2adc..48d25519c8 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,26 +1,66 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; -import { legacyDbResetCommand } from "./reset.command.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbConfigConnectTempRoleError } from "../../../shared/legacy-db-config.errors.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; -function setupLegacyDbReset() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); - }), - execCapture: () => Effect.succeed(""), - }); - return { layer, calls }; -} +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; -const baseFlags: LegacyDbResetFlags = { +const CONN: LegacyPgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbResetFlags = { dbUrl: Option.none(), linked: false, local: false, @@ -30,118 +70,1327 @@ const baseFlags: LegacyDbResetFlags = { last: Option.none(), }; +/** + * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a + * connection was (or, for the delegated-experimental path, was NOT) resolved — + * `resolve()` mints/verifies a temporary Postgres login role over the Management + * API, so calling it on a path that immediately discards the result is wasted + * (and duplicated) work (CLI-1879). + */ +function mockResolver(opts: { + isLocal: boolean; + ref?: string; + omitRef?: boolean; + resolveFails?: boolean; +}) { + let calls = 0; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => { + calls++; + return opts.resolveFails === true + ? Effect.fail( + new LegacyDbConfigConnectTempRoleError({ + message: "failed to create login role: network error", + }), + ) + : Effect.succeed( + (opts.omitRef === true + ? { conn: CONN, isLocal: opts.isLocal } + : { + conn: CONN, + isLocal: opts.isLocal, + ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + }) satisfies LegacyResolvedDbConfig, + ); + }, + resolvePoolerFallback: () => { + calls++; + return Effect.succeed(Option.none()); + }, + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function mockConnection(opts: { remoteSeeds?: Readonly> }) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.sync(() => { + execs.push(sql); + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === SELECT_SEEDS) { + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records + * the recreate args so tests can assert version / `--no-seed` propagation. + * `awaitStorageReadyExitCode`, when set, fails `awaitStorageReady` with a + * `LegacyGoChildExitError` carrying that code — simulating the seam's real + * `captureStdout` bootstrap-child path exiting non-zero (CLI-1879). + */ +function mockBootstrapSeam(opts: { + running?: boolean; + storageReady?: boolean; + awaitStorageReadyExitCode?: number; +}) { + const recreateCalls: Array<{ + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }> = []; + let storageChecked = false; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => Effect.succeed(opts.running ?? true), + startDatabase: () => Effect.void, + recreateDatabase: (args: { + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }) => + Effect.sync(() => { + recreateCalls.push(args); + }), + awaitStorageReady: () => + Effect.sync(() => { + storageChecked = true; + }).pipe( + Effect.flatMap(() => + opts.awaitStorageReadyExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.awaitStorageReadyExitCode, + message: `failed to bootstrap the local database: exit ${opts.awaitStorageReadyExitCode}`, + }), + ) + : Effect.succeed(opts.storageReady ?? false), + ), + ), + }); + return { + layer, + get recreateCalls() { + return recreateCalls; + }, + get storageChecked() { + return storageChecked; + }, + }; +} + +// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage +// is ready AND buckets are configured (no reset test configures buckets, so the +// gateway is never actually called). Present to satisfy the handler's R. +const mockStorageHttp = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +/** + * `execCaptureExitCode`, when set, makes `execCapture` fail with a + * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating + * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). + */ +function mockProxy(opts: { execCaptureExitCode?: number } = {}) { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => + Effect.sync(() => { + calls.push({ args, env: execOpts?.env }); + }), + execCapture: (args, execOpts) => + Effect.sync(() => { + calls.push({ args, env: execOpts?.env }); + }).pipe( + Effect.flatMap(() => + opts.execCaptureExitCode !== undefined + ? Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.execCaptureExitCode, + message: `supabase-go exited with code ${opts.execCaptureExitCode}`, + }), + ) + : Effect.succeed(""), + ), + ), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + isLocal?: boolean; + ref?: string; + experimental?: boolean; + remoteSeeds?: Readonly>; + yes?: boolean; + omitRef?: boolean; + resolveFails?: boolean; + running?: boolean; + storageReady?: boolean; + awaitStorageReadyExitCode?: number; + execCaptureExitCode?: number; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); + const seam = mockBootstrapSeam({ + running: opts.running, + storageReady: opts.storageReady, + awaitStorageReadyExitCode: opts.awaitStorageReadyExitCode, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on `--local` (projectRef === ""). + const platformApi = mockLegacyPlatformApiService({}); + const resolver = mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + resolveFails: opts.resolveFails, + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + proxy.layer, + seam.layer, + resolver.layer, + mockLegacyCliConfig({ workdir }), + BunServices.layer, + mockRuntimeInfo(), + // The remote-reset confirmation is answered through mockOutput's + // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is + // only referenced by legacyPromptYesNo's non-TTY branch (unreached here) but must + // be present to satisfy the effect's requirements. + mockTty({ stdinIsTty: true }), + mockStdin(true), + // The linked ref is pre-loaded (for the post-run cache) before resolve, + // mirroring Go's LoadProjectRef-before-NewDbConfigWithPassword order. + Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + }), + mockStorageHttp, + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, proxy, seam, telemetry, linkedCache, resolver }; +} + +const migrationFile = (version: string, body = "create table t ();") => ({ + [`supabase/migrations/${version}_test.sql`]: body, +}); + describe("legacy db reset", () => { - it.live("forwards the empty-array baseline without seed override flags", () => { - const { layer, calls } = setupLegacyDbReset(); + const tmp = useLegacyTempWorkdir("supabase-db-reset-"); + + it.live("resets the local database via the bootstrap seam", () => { + const { layer, out, seam, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { - yield* legacyDbReset(baseFlags); - expect(calls).toEqual([["db", "reset"]]); - }).pipe(Effect.provide(layer)); + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Native path — no Go delegation. + expect(proxy.calls).toHaveLength(0); + expect(out.stderrText).toContain("Resetting local database..."); + expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false, sqlPaths: [] }]); + // Storage gate checked; with no buckets configured nothing is seeded. + expect(seam.storageChecked).toBe(true); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + }); }); - it.live("forwards --no-seed alone", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("fails a local reset when the database is not running", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); return Effect.gen(function* () { - yield* legacyDbReset({ ...baseFlags, noSeed: true }); - expect(calls).toEqual([["db", "reset", "--no-seed"]]); - }).pipe(Effect.provide(layer)); + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(seam.recreateCalls).toHaveLength(0); + }); }); - it.live("forwards a single --sql-paths flag", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("proceeds with a local reset when no config file is present", () => { + // Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty + // `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate` + // never sees an empty required field and the CLI proceeds — exactly the mechanism + // the cli-e2e parity suite relies on when it runs `db reset --local` from a project + // with no config.toml. A missing config must not become a hard failure here. + const { layer, seam } = setup(tmp.current, { + args: ["db", "reset"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { - yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql"], + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => { + // Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs + // full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` / + // `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must + // abort before the local database is ever recreated. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live( + "fails a local reset on a malformed config.toml even when the database is not running", + () => { + // Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`, + // strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning` + // (`internal/db/reset/reset.go:57`). So a broken config must surface as a config + // error even when the local database is ALSO not running — the config check must + // win the race, not the "is not running" check. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("failed to load config"); + expect(cause).not.toContain("is not running."); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }, + ); + + it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => { + // Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before + // `reset.Run` recreates the local database, so an undecryptable secret must abort + // before the destructive recreate, not surface later (or never) during bucket + // seeding. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + // Assert on the stable "failed to parse config:" prefix rather than the exact + // decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*` + // is set (missing key vs. a base64/decrypt failure) — either way, the config + // load must fail before the destructive recreate. + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config:"); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("fails a local reset before the destructive recreate on an empty project_id", () => { + // Go's `config.Validate` rejects an explicit `project_id = ""` (a present override + // that resolved to empty, unlike an absent field) before the local recreate. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = ""\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + // No buckets configured → the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.storageChecked).toBe(true); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => { + // `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so + // `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches + // `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much + // later (if at all) via the bucket-seeding core's own reload, AFTER the local + // database had already been recreated — this must now abort up front instead, + // via the pre-recreate `legacyCheckDbToml` gate. + const previous = process.env["SEED_ENABLED"]; + process.env["SEED_ENABLED"] = "maybe"; + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled"); + } + expect(seam.recreateCalls).toHaveLength(0); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SEED_ENABLED"]; + else process.env["SEED_ENABLED"] = previous; + }), + ), + ); + }); + + it.live( + "finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw", + () => { + // `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via + // `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees + // `supabase/.env.development` — a real, Go-valid env source + // (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this + // is genuinely ambient env by the time Go itself resolves `env(VAR)`, + // `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes + // through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads + // `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts: + // 209-245) — it can't see `.env.development` at all. So this Go-valid config + // passes the gate and the real recreate, then can't be re-resolved by the + // reload; the reset must still finish (warn-and-skip), not hard-fail after the + // local database has already been dropped and rebuilt. + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', + files: { "supabase/.env.development": "SEED_ENABLED=true\n" }, + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toHaveLength(1); + expect(out.stderrText).toContain("skipped seeding storage buckets"); + expect(out.stderrText).toContain("Finished "); }); - expect(calls).toEqual([["db", "reset", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + }, + ); + + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + // `detectGitBranch` checks `$GITHUB_HEAD_REF` first (matching Go's + // `GetGitBranchOrDefault`). Set it explicitly so the test is deterministic in + // both a plain checkout and a GitHub Actions PR run (where it is preset to the + // PR branch); restore it afterwards. + const previous = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = "feature-x"; + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previous; + }), + ), + ); }); - it.live("forwards repeated --sql-paths flags in order", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message, same as the other db + // commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); + }); + + it.live("loads a Go-style env() boolean in config for a remote reset", () => { + // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool + // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. + const previous = process.env["MIGRATIONS_ENABLED"]; + process.env["MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; + else process.env["MIGRATIONS_ENABLED"] = previous; + }), + ), + ); + }); + + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); + }); + + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); + }); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { + // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, + // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs + // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must + // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + // Config load failed before ResetAll → schemas were never dropped. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so + // the native remote reset must abort before `legacyDropUserSchemas`. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = ""\n', + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }); + }); + + it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so + // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("still caches the linked ref when DB-config resolution fails", () => { + // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on + // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via + // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed + // linked resolve must not skip the post-run linked-project cache write. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + resolveFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, - sqlPaths: ["./seeds/base.sql", "./seeds/demo/*.sql"], + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); + }); + + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); + }); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); + }); + + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live("does not resolve a linked DB connection before delegating an experimental reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // The delegated Go child re-runs its own connection resolution (including + // minting/verifying the temp login role) once it starts — the TS wrapper + // must not do that same Management-API work first only to discard it (CLI-1879). + expect(resolver.calls).toBe(0); + }); + }); + + it.live("still caches the linked ref when delegating an experimental reset", () => { + // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` + // separately from `resolver.resolve()`, specifically so the post-run + // linked-project-cache finalizer still fires on this path even though + // `resolve()` itself is skipped entirely (CLI-1879). + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live( + "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + format: "json", + execCaptureExitCode: 3, + }); + return Effect.gen(function* () { + // Under json/stream-json, the delegated path uses `execCapture` (non-text + // branch of `delegateExperimentalReset`) — this must flow through the normal + // Effect failure channel (reachable by `withJsonErrorHandling` at the + // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a + // handler-level test could never observe (CLI-1879). + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } }); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + }, + ); + + it.live( + "propagates the storage-ready check's exact exit code and still flushes telemetry on a local reset", + () => { + // The bootstrap seam's `awaitStorageReady` (the `captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with, and the handler's own `Effect.ensuring(telemetryState.flush)` + // finalizer must still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + awaitStorageReadyExitCode: 4, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(4); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + + it.live("forwards the linked selector to the delegate even for --linked=false", () => { + // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in + // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls + // back to its local default and resets the wrong database. + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + }); + }); + + it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { + // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the + // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and + // drop the remote schemas the user tried to protect. + const previous = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "true"; + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=false"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = previous; + }), + ), + ); + }); + + it.live("forwards --yes=true to the delegate when --yes is set", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes"], + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=true"); + }); + }); + + it.live( + "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + () => { + // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote + // reset delegates to the Go binary rather than replaying migrations natively. + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + // No experimental flag / shell env — only the project .env sets it. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // Delegated, so the native remote path never dropped schemas. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = previous; + }), + ), + ); + }, + ); + + it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. + expect(JSON.stringify(exit.cause)).toContain("Use either"); + } + }); + }); + + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", + "--yes=false", ]); - }).pipe(Effect.provide(layer)); + // Unlike the `connType === "linked"` branch above, a `--db-url` target still + // resolves a connection before delegating — the pre-delegation skip (CLI-1879) + // is scoped to the linked branch only, not "never call resolve when delegating". + expect(resolver.calls).toBe(1); + }); }); - it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { + // last=1 with 2 local migrations → recreate up to version 20240101000000. yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + local: true, noSeed: true, - sqlPaths: ["./seeds/base.sql"], - }); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + last: Option.some(1), + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: true, sqlPaths: [] }, + ]); + }); }); - it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: false, sqlPaths: [] }, + ]); + }); + }); + + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); + }); + + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); + }); + + it.live("rejects --no-seed together with --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + } + }); + }); + + it.live("rejects an empty --sql-paths value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, sqlPaths: [""], - }); - expect(calls).toEqual([["db", "reset", "--sql-paths", ""]]); - }).pipe(Effect.provide(layer)); - }); - - it("parses repeated --sql-paths flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--sql-paths", - "./seeds/base.sql", - "--sql-paths", - "./seeds/demo/*.sql", - ]); - expect(calls).toEqual([ - ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], - ]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--sql-paths requires a non-empty path or glob pattern", + ); + } + }); }); - it("forwards mutually exclusive seed flags from the command surface", async () => { - const { layer, calls } = setupLegacyDbReset(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ - "--no-seed", - "--sql-paths", - "./seeds/base.sql", - ]); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect, - ); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); + }); + + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); + }); + + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + const { layer, out } = setup(tmp.current, { + // Seed disabled in config — --sql-paths must force-enable it. + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); + }); + + it.live("forwards --sql-paths to the recreate seam on a local reset", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql", "demo/*.sql"], + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "", noSeed: false, sqlPaths: ["custom-seed.sql", "demo/*.sql"] }, + ]); + }); + }); + + it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--linked", + "--sql-paths", + "custom-seed.sql", + "--yes=false", + ]); + }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts new file mode 100644 index 0000000000..7cf648f3fe --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -0,0 +1,80 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: + * the Postgres connection, the db-config resolver, project-ref resolution, and the + * linked-project cache, all over the lazy management-API factory so the local / + * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` + * (used to delegate the local / experimental reset paths) is ambient from the root. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbResetRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + // Exposed (not just provided to `projectRef`) because the local reset path reuses + // the seed-buckets core, whose `legacyResolveStorageCredentials` requires the + // (lazy) Management-API factory for the linked branch — never hit on `--local`, + // but a static service requirement of the shared core. + platformApiFactory, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // `legacyPromptYesNo`'s non-TTY branch reads the piped answer via `Stdin` (Go's + // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the + // confirmation prompt fails with a missing-service defect instead of the default. + stdinLayer, + // Container-recreate / storage-health primitives for the native local reset. + legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + commandRuntimeLayer(["db", "reset"]), +); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index a97de5224d..4ff6cb9ab5 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -29,8 +29,8 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( /** * A mutually-exclusive flag group was violated. Reproduces cobra's * `MarkFlagsMutuallyExclusive` `ValidateFlagGroups` error byte-for-byte: - * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:499`) - * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:490`) + * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:570`) + * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:561`) * Both fail before any side effects run, matching cobra's pre-RunE validation. */ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts index 506af3485d..bd4777e545 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts @@ -28,11 +28,17 @@ export function legacyPgDeltaSuggestion(configPath: string): string { } /** - * The Effect-CLI replacement for Go's `PersistentPreRunE` gate: invoke at the - * top of each declarative leaf handler. Fails with - * `LegacyDeclarativeNotEnabledError` (carrying the byte-exact message + - * suggestion) when neither `--experimental` nor `[experimental.pgdelta]` enables - * pg-delta. + * The Effect-CLI replacement for Go's `dbDeclarativeCmd.PersistentPreRunE` gate + * (`apps/cli-go/cmd/db_schema_declarative.go:49-99`). Cobra runs + * `PersistentPreRunE` BEFORE `ValidateFlagGroups()` (mutual-exclusivity checks) + * and `RunE` (`cobra@v1.10.2/command.go:985,1010,1014`), so this gate must run + * before the `MarkFlagsMutuallyExclusive` check in the same command — `db-url`/ + * `linked`/`local` on `generate` (`:570`), `apply`/`no-apply` on `sync` (`:561`) + * — a closed gate must win over a flag-group conflict, not the other way + * around. Invoke at the top of each declarative leaf handler's body, before + * that handler's mutex check. Fails with `LegacyDeclarativeNotEnabledError` + * (carrying the byte-exact message + suggestion) when neither `--experimental` + * nor `[experimental.pgdelta]` enables pg-delta. */ export const legacyRequirePgDelta = Effect.fnUntraced(function* (opts: { readonly experimental: boolean; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 7e6cbab323..77fb087c26 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -169,9 +169,10 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // Use the non-exiting seam (not LegacyGoProxy.exec, which process.exits on a - // non-zero child and would skip the handler's telemetry flush / error handling), - // and propagate a failure on a non-zero reset exit. + // `execInherit` (not `LegacyGoProxy.exec`) returns the child's exit code as a + // catchable value rather than exiting the host process — the same + // typed-failure design CLI-1879 gave `LegacyGoProxy.exec` itself, predating + // it here as its own seam. Propagate a failure on a non-zero reset exit. const seam = yield* LegacyDeclarativeSeam; // Forward --network-id: Go's in-process reset.Run honors the root viper // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 5df90ac1a9..bd009a4af3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -47,16 +47,28 @@ pg-delta catalog (source) against the target database's catalog (target). | Code | Condition | | ---- | --------------------------------------------------------------------- | | `0` | success (files written, or skipped after a declined prompt) | -| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | +| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | | `1` | shadow-database / edge-runtime / export failure | +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before a `--db-url`/`--linked`/`--local` conflict is ever checked. + ## Output -Text mode only (no machine envelope). Diagnostics + the final -`Declarative schema written to ` go to stderr; the PostRun prints -`Finished supabase db schema declarative generate.` to stdout on success. +Diagnostics (target resolution, prompts, `Declarative schema written to `) +always go to stderr, in every `--output-format`. On success: + +- `text` mode prints `Finished supabase db schema declarative generate.` to + stdout (matches Go's PostRun `fmt.Println`, `cmd/db_schema_declarative.go:116-118`). +- `json`/`stream-json` mode instead emits a structured success envelope + (`output.success("Finished supabase db schema declarative generate.")`) so + the machine stdout payload isn't corrupted by a bare human line + (`generate.command.ts:74-90`, CLI-1546 invariant). ## Notes diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index fbcb89a721..3cec545df7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,8 +1,8 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { - LegacyExperimentalFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -10,6 +10,7 @@ import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service import { legacyBold } from "../../../../../shared/legacy-colors.ts"; import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -44,7 +45,14 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; // The resolved linked ref (explicit `--linked` only), hoisted so the post-run @@ -52,9 +60,23 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. The pg-delta gate also runs on + // the BASE config: Go's declarative `PersistentPreRunE` gates before the root + // `ParseDatabaseConfig` reloads any `[remotes.]` block, so a remote + // `experimental.pgdelta.enabled = true` must NOT enable a base-disabled + // command without `--experimental`. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: baseToml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` - // (`apps/cli-go/cmd/db_schema_declarative.go:499`) runs before PreRunE/RunE, - // so reject conflicting targets before reading config or the pg-delta gate. + // (`apps/cli-go/cmd/db_schema_declarative.go:570`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. // "Set" follows cobra's `Changed`: Option set when `Some`, boolean when `true`. const exclusive: Array = []; if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); @@ -68,17 +90,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec ); } - const baseToml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - // The pg-delta gate runs on the BASE config: Go's declarative `PersistentPreRunE` - // gates before the root `ParseDatabaseConfig` reloads any `[remotes.]` block, - // so a remote `experimental.pgdelta.enabled = true` must NOT enable a - // base-disabled command without `--experimental`. - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: baseToml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); - // Explicit `--linked`: Go re-loads config with the resolved ref (root // `ParseDatabaseConfig` linked branch), so a matching `[remotes.]` block // overrides `experimental.pgdelta.*` (declarative_schema_path / format_options) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 8241336bd5..48ade9b8a6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -12,6 +12,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -48,6 +49,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; promptConfirmResponses?: ReadonlyArray; @@ -147,6 +149,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "generate"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), @@ -204,10 +207,11 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects conflicting targets (--local --linked) before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local") runs before - // PreRunE, so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--local --linked with --experimental fails with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate( @@ -223,6 +227,118 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--local --linked without --experimental fails with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "generate", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), linked: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("explicit --local: provisions baseline, exports, writes declarative files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 53d1a64fad..e2182911be 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -23,12 +23,12 @@ as a new timestamped migration. ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script | always | -| `supabase-go migration up --local` | when the migration is applied (`--apply` / prompt / `--yes`) | +| What | When | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `supabase-go db schema declarative __catalog --mode migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | +| Edge-runtime container running the pg-delta diff Deno script | always | +| `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -42,14 +42,20 @@ as a new timestamped migration. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success (migration created, applied, or "No schema changes found") | -| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | -| `1` | pg-delta not enabled | -| `1` | no declarative schema files found | -| `1` | shadow-database / edge-runtime / diff failure | -| `1` | apply failure (when applied) — propagated from `migration up` | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------- | +| `0` | success (migration created, applied, or "No schema changes found") | +| `1` | pg-delta not enabled | +| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | +| `1` | no declarative schema files found | +| `1` | shadow-database / edge-runtime / diff failure | +| `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | + +The pg-delta gate and the mutex check are both raised before any side effects run, +but the gate wins when both conditions apply simultaneously: Go's +`PersistentPreRunE` runs before `ValidateFlagGroups()` +(`cobra@v1.10.2/command.go:985,1010`), so a closed gate (missing `--experimental`) +surfaces before an `--apply`/`--no-apply` conflict is ever checked. ## Output diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index e06c092a1f..db9da924de 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -33,7 +33,7 @@ const config = { Flag.optional, ), // cobra's `MarkFlagsMutuallyExclusive("apply", "no-apply")` keys off `flag.Changed`, - // not the value (`cmd/db_schema_declarative.go:490`), so model presence with `Option` + // not the value (`cmd/db_schema_declarative.go:561`), so model presence with `Option` // so `--apply=false --no-apply` still trips the conflict. The apply decision below // reads the resolved value via `Option.getOrElse`. apply: Flag.boolean("apply").pipe( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 61f276e4c0..63f8d8a7e4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -2,9 +2,9 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, + legacyResolveExperimentalWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; @@ -13,6 +13,7 @@ import { legacyBold, legacyRed, legacyYellow } from "../../../../../shared/legac import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; import { + legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; @@ -72,7 +73,14 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const path = yield* Path.Path; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; - const experimental = yield* LegacyExperimentalFlag; + // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs + // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading + // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ + // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env + // first and resolve against it, as `db reset` does for its own experimental gate, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); const yes = yield* LegacyYesFlag; const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; @@ -89,10 +97,21 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Gate before the mutex check below — order matters; see + // legacyRequirePgDelta's doc comment for why. + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + // cobra `MarkFlagsMutuallyExclusive("apply", "no-apply")` - // (`apps/cli-go/cmd/db_schema_declarative.go:490`) runs before PreRunE/RunE, - // so reject the conflict before reading config or the pg-delta gate, rather - // than letting `--no-apply` silently win in the apply-decision helper. + // (`apps/cli-go/cmd/db_schema_declarative.go:561`) runs via + // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the + // gate above) — see legacyRequirePgDelta's doc comment for the full ordering. + // Reject the conflict here rather than letting `--no-apply` silently win in + // the apply-decision helper. const exclusive: Array = []; if (Option.isSome(flags.apply)) exclusive.push("apply"); if (Option.isSome(flags.noApply)) exclusive.push("no-apply"); @@ -104,12 +123,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - yield* legacyRequirePgDelta({ - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - configPath: path.join("supabase", "config.toml"), - }); // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index d1e83e7290..44dd7548b2 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -11,6 +11,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -44,6 +45,7 @@ const EXPORT_JSON = JSON.stringify({ interface SetupOpts { experimental?: boolean; + args?: ReadonlyArray; yes?: boolean; stdinIsTty?: boolean; diffSql?: string; @@ -151,6 +153,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "sync"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed( LegacyNetworkIdFlag, @@ -199,10 +202,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --apply and --no-apply together before the pg-delta gate", () => { - // cobra MarkFlagsMutuallyExclusive("apply", "no-apply") runs before PreRunE, - // so this fails even when pg-delta is not enabled. - const { layer } = setup(tmp.current, { experimental: false }); + it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { + // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs + // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, + // 1010), so the mutex error only surfaces once the gate is open. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( @@ -218,10 +222,122 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "--apply and --no-apply together without --experimental fail with the gate error, not the mutex error", + () => { + // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): + // the pg-delta gate runs before the mutex check, so an unopened gate wins even + // when the flags would also violate mutual exclusivity. + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL env (no --experimental flag) fail with the mutex error", + () => { + // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), + // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), + // so an env-only experimental session still opens the gate and lets the mutex + // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is + // what makes the TS gate honor the env var the same way. + const { layer } = setup(tmp.current, { experimental: false }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", + () => { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the + // gate instead of letting the env value override it. + const { layer } = setup(tmp.current, { + experimental: false, + args: ["db", "schema", "declarative", "sync", "--experimental=false"], + }); + const ENV = "SUPABASE_EXPERIMENTAL"; + return Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--apply and --no-apply together with SUPABASE_EXPERIMENTAL set only in the project .env fail with the mutex error", + () => { + // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) + // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") + // (apps/cli-go/cmd/db_schema_declarative.go:73-78, pkg/config/config.go:789), so a + // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex + // check fire, same as the shell-env case above. + const saved = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ apply: Option.some(true), noApply: Option.some(true) }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", + message: + "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = saved; + }), + ), + ); + }, + ); + it.effect("rejects --apply=false --no-apply as a conflict (Go flag.Changed)", () => { // cobra keys the mutex off flag.Changed, so an explicit `--apply=false` still // counts as set and conflicts with `--no-apply`, even though its value is false. - const { layer } = setup(tmp.current, { experimental: false }); + // The gate runs first (see legacyRequirePgDelta's doc comment), so --experimental + // is required here for the mutex error to be the one that surfaces. + const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts new file mode 100644 index 0000000000..673e78a466 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -0,0 +1,20 @@ +import { Data } from "effect"; + +/** + * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the + * container-lifecycle primitives that back native `db start` / `db reset --local` + * (create/recreate the local Postgres container, apply the initial schema, the + * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, + * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own + * progress to stderr, so this message is the fallback shown when the subprocess + * dies without surfacing a more specific Go error. + */ +export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ + readonly message: string; + /** + * Optional actionable hint rendered as a separate "Suggestion:" line, mirroring + * Go's `utils.CmdSuggestion` — set to the Docker-install hint when the container + * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). + */ + readonly suggestion?: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts new file mode 100644 index 0000000000..d6b1793166 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -0,0 +1,252 @@ +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + LegacyNetworkIdFlag, + LegacyProfileFlag, + legacyResolveExperimental, +} from "../../../../shared/legacy/global-flags.ts"; +import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveLocalProjectId, + localDbContainerId, +} from "../../../shared/legacy-docker-ids.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../../shared/legacy-docker-suggest.ts"; +import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; + +const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); + +const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes); +}; + +/** + * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden + * `db __db-bootstrap --mode ` command. The binary is resolved exactly like + * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its + * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a + * flag-selected `--profile` are forwarded so the spawned containers land on the + * same network and the child re-runs Go's identical config resolution. + */ +export const legacyDbBootstrapSeamLayer = Layer.effect( + LegacyDbBootstrapSeam, + Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; + const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / + // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a + // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const experimentalArgs = experimental ? ["--experimental"] : []; + const spawner = yield* ChildProcessSpawner; + const processControl = yield* ProcessControl; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = resolveBinary(); + + /** + * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes + * stdout (for the `await-storage` marker); otherwise stdout is inherited. + * Returns the captured stdout (empty when inherited). + */ + const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + return yield* Effect.fail( + seamFailure( + "Could not find the supabase-go binary required to bootstrap the local database.", + ), + ); + } + // `runCli` treats `db start`/`db reset` as self-managed and installs no + // global signal handler, and this direct child spawn (unlike + // `LegacyGoProxy.exec`) inherits the foreground process group. Hold + // SIGINT/SIGTERM/SIGHUP with no-op listeners so an interactive Ctrl-C + // during container startup/restore does not default-terminate the TS + // parent out from under the Go child's docker-cleanup path — the parent + // stays blocked on the child's exit and propagates its real status. + // Scoped, so the listeners are removed on completion/failure/interrupt. + yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + const args = [ + "db", + "__db-bootstrap", + ...modeArgs, + ...networkArgs, + ...profileArgs, + ...experimentalArgs, + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: captureStdout ? "pipe" : "inherit", + stderr: "inherit", + extendEnv: true, + // Disable the child's telemetry so the hidden seam never records its + // own `cli_command_executed` on top of the user's TS command, matching + // the `db __shadow` seam and the explicit LegacyGoProxy delegates. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + if (!captureStdout) { + const exitCode = yield* spawner + .exitCode(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + if (exitCode !== 0) { + // `LegacyGoChildExitError` (not `seamFailure`/`processControl.exit`) so the + // handler's finalizers — `Effect.ensuring(telemetryState.flush)` + the legacy + // command instrumentation — still run (an immediate `process.exit` would skip + // them), AND the child's exact exit code (e.g. 130 after Ctrl-C cleanup) reaches + // `runCli`'s `processControl.exit()` instead of collapsing to a generic 1. The + // child's detailed failure is already on the inherited stderr, so `runCli` + // special-cases this error class to suppress its own normally-would-print + // generic stderr line — Go itself never prints a second line here. CLI-1879. + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), + ); + } + return ""; + } + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + const chunks: Array = []; + yield* Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + chunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), + ); + if (exitCode !== 0) { + // See the `!captureStdout` branch above for why `LegacyGoChildExitError` + // replaces `seamFailure` here — same exact-code + finalizer + no-duplicate-line + // reasoning (CLI-1879). + return yield* Effect.fail( + new LegacyGoChildExitError({ + exitCode, + message: `failed to bootstrap the local database: exit ${exitCode}`, + }), + ); + } + return decodeChunks(chunks); + }), + ); + + return LegacyDbBootstrapSeam.of({ + isDbRunning: () => + Effect.scoped( + Effect.gen(function* () { + // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir + // basename); the config.toml read is best-effort (`validate: false`) since + // the handler has already run Go's `LoadConfig` validation — an invalid + // config would have failed there, so here we only want the `projectId` and + // tolerate a fallback to the workdir basename rather than re-throwing. + const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { + validate: false, + }).pipe( + Effect.map((toml) => toml.projectId), + // The lenient read still surfaces a genuinely unreadable/malformed project + // `.env`; fall back to the workdir basename in that case rather than failing + // the running-check (the handler has already validated config). + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not + // running. Discard stdout (the inspect JSON) so the unconsumed pipe can + // never deadlock; only the exit code + stderr matter. + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => seamFailure("failed to inspect service")), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + const stderr = decodeChunks(stderrChunks).trim(); + // Only a missing container means "not running". Docker reports this as + // either "No such container" or "No such object" depending on daemon + // version/CLI path (the same pair handled in `shared/functions/serve.ts`). + // Any other inspect failure (e.g. the Docker daemon is down) propagates, + // matching Go's `AssertSupabaseDbIsRunning`. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { + // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` + // on a daemon-connection failure (`misc.go:148-154`), so a down daemon + // still surfaces the actionable Docker Desktop hint, not just raw stderr. + return yield* Effect.fail( + new LegacyDbBootstrapError({ + message: + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ...(legacyIsDockerDaemonUnreachable(stderr) + ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } + : {}), + }), + ); + } + return false; + }), + ), + startDatabase: ({ fromBackup }) => + runBootstrap( + ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], + false, + ).pipe(Effect.asVoid), + recreateDatabase: ({ version, noSeed, sqlPaths }) => + runBootstrap( + [ + "--mode", + "recreate", + ...(version !== "" ? ["--version", version] : []), + ...(noSeed ? ["--no-seed"] : []), + ...sqlPaths.flatMap((p) => ["--sql-paths", p]), + ], + false, + ).pipe(Effect.asVoid), + awaitStorageReady: () => + runBootstrap(["--mode", "await-storage"], true).pipe( + Effect.map((stdout) => stdout.trim() === "ready"), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts new file mode 100644 index 0000000000..157b290c4b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -0,0 +1,72 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; + +/** + * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing + * the container-bootstrap primitives that native `db start` / `db reset --local` + * still need but that are not ported to TypeScript: the local-stack "is running?" + * probe, the database container create/recreate flows, and the storage health gate + * before bucket seeding. The TS handlers orchestrate everything else (user-facing + * messages, version resolution, bucket seeding, the git-branch line, telemetry, + * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * + * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to + * the same resolved `supabase-go`, with the child's telemetry disabled so the + * hidden seam never double-counts the user's command, and its progress teed to + * stderr. + */ +interface LegacyDbBootstrapSeamShape { + /** + * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect + * the local Postgres container. `true` when it exists (the stack is up), `false` + * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other + * inspect failure (e.g. the Docker daemon is unreachable) fails with + * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect + * error rather than treating the database as stopped. + */ + readonly isDbRunning: () => Effect.Effect; + /** + * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's + * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): + * create the Postgres container, wait for health, apply the initial schema + + * roles + migrations + seed on a fresh volume, and write `_current_branch`. + * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. + */ + readonly startDatabase: (opts: { + readonly fromBackup?: string; + }) => Effect.Effect; + /** + * The PG14/PG15 container-recreate half of local `db reset` + * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, + * migrate + seed up to `version`, and restart the satellite containers. The + * caller has already printed `Resetting local database…`; the seam tees the + * remaining progress (`Recreating database...`, `Restarting containers...`) to + * stderr. `version` is the resolved migration version ("" for all migrations); + * `noSeed` disables the seed and `sqlPaths` overrides `[db.seed].sql_paths` + * inside the recreate's MigrateAndSeed, mirroring the `db reset` + * `--no-seed` / `--sql-paths` handling (`cmd/db.go` `dbResetCmd`). + */ + readonly recreateDatabase: (opts: { + readonly version: string; + readonly noSeed: boolean; + readonly sqlPaths: ReadonlyArray; + }) => Effect.Effect; + /** + * The storage health gate local `db reset` runs before seeding buckets + * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, + * wait up to 30s for it. Resolves `true` when the storage container exists (so + * the caller should run the ported bucket seeding) and `false` when it does not + * — matching Go, which silently skips buckets when storage is absent. + */ + readonly awaitStorageReady: () => Effect.Effect< + boolean, + LegacyDbBootstrapError | LegacyGoChildExitError + >; +} + +export class LegacyDbBootstrapSeam extends Context.Service< + LegacyDbBootstrapSeam, + LegacyDbBootstrapSeamShape +>()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts new file mode 100644 index 0000000000..88d394498c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect"; + +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` + * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, + * extensions, public-schema objects, and non-managed publications, then + * truncates the auth / supabase_functions / supabase_migrations tables. Run as a + * single simple-query statement, matching Go's one-statement `ExecBatch`. + */ +const DROP_OBJECTS = `do $$ declare + rec record; +begin + -- schemas + for rec in + select pn.* + from pg_namespace pn + left join pg_depend pd on pd.objid = pn.oid + where pd.deptype is null + and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) + and pn.nspowner::regrole::text != 'supabase_admin' + loop + -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also + -- drop the extension. But if an extension creates its own schema, dropping the schema will + -- throw an error. Hence, we drop schemas first while excluding those created by extensions. + raise notice 'dropping schema: %', rec.nspname; + execute format('drop schema if exists %I cascade', rec.nspname); + end loop; + + -- extensions + for rec in + select * + from pg_extension p + where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') + loop + raise notice 'dropping extension: %', rec.extname; + execute format('drop extension if exists %I cascade', rec.extname); + end loop; + + -- functions + for rec in + select * + from pg_proc p + where p.pronamespace::regnamespace::name = 'public' + loop + -- supports aggregate, function, and procedure + raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; + execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); + end loop; + + -- views (necessary for views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'v' + loop + raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'm' + loop + raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- tables (cascade to dependent objects) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind not in ('c', 'S', 'v', 'm') + order by c.relkind desc + loop + -- supports all table like relations, except views, complex types, and sequences + raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- truncate tables in auth, webhooks, and migrations schema + for rec in + select * + from pg_class c + where + (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' + or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' + or c.relnamespace::regnamespace::name = 'supabase_migrations') + and c.relkind = 'r' + loop + raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- sequences + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 's' + loop + raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- types + for rec in + select * + from pg_type t + where + t.typnamespace::regnamespace::name = 'public' + and typtype != 'b' + loop + raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; + execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); + end loop; + + -- policies + for rec in + select * + from pg_policies p + loop + raise notice 'dropping policy: %', rec.policyname; + execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); + end loop; + + -- publications + for rec in + select * + from pg_publication p + where + not p.pubname like any(array['supabase\\_realtime%', 'realtime\\_messages%']) + loop + raise notice 'dropping publication: %', rec.pubname; + execute format('drop publication if exists %I', rec.pubname); + end loop; +end $$;`; + +/** + * Drops all user-created database objects, mirroring Go's + * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` + * block runs as a single transactional statement (no migration-history row). + */ +export const legacyDropUserSchemas = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + // Go's `DropUserSchemas` runs only `drop.sql` via `ExecBatch` (drop.go:34-38) — + // no `RESET ALL`. Resetting here would clear caller-supplied DB URL runtime + // params (e.g. `options=-c statement_timeout=…`) before the destructive drop, so + // the remote `db reset --db-url` path must NOT reset (matches Go's ExecBatch). + yield* session.exec("BEGIN"); + yield* session + .exec(DROP_OBJECTS) + .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + yield* session.exec("COMMIT"); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts new file mode 100644 index 0000000000..579a0b675a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts @@ -0,0 +1,123 @@ +import { legacyBold } from "../../../shared/legacy-colors.ts"; + +/** + * `pkg/migration/file.go` — local migration filenames are `_.sql`. + * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the + * version capture group is always present. + */ +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** Last path segment, mirroring Go's `filepath.Base`. */ +const baseName = (path: string): string => { + const normalized = path.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** + * `pkg/migration/apply.go:14-16` — the exact error strings Go raises so the legacy + * handler can byte-match them on stderr. + */ +export const LEGACY_ERR_MISSING_REMOTE = + "Found local migration files to be inserted before the last migration on remote database."; +export const LEGACY_ERR_MISSING_LOCAL = + "Remote migration versions not found in local migrations directory."; + +/** + * The outcome of comparing local migration files against the remote + * `schema_migrations` history. Pure 1:1 port of Go's `FindPendingMigrations` + * (`pkg/migration/apply.go:21-54`). + * + * - `ok` — `pending` are the local migration paths to apply (those + * beyond the remote history, in order). + * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). + * `versions` are the offending remote versions. + * - `missing-remote`— local has files ordered before the remote head + * (`ErrMissingRemote`). `paths` are the out-of-order local + * migration paths. + */ +export type LegacyPendingMigrations = + | { readonly kind: "ok"; readonly pending: ReadonlyArray } + | { readonly kind: "missing-local"; readonly versions: ReadonlyArray } + | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray }; + +/** + * Two-pointer reconciliation of local migration paths vs remote applied versions. + * Mirrors Go's `FindPendingMigrations` exactly, including its **string** + * comparison of versions (`remote == local` / `remote < local`) — version + * prefixes are fixed-width timestamps, so lexical order equals chronological + * order, matching Go. + */ +export function legacyFindPendingMigrations( + localMigrations: ReadonlyArray, + remoteMigrations: ReadonlyArray, +): LegacyPendingMigrations { + const unapplied: Array = []; + const missing: Array = []; + let i = 0; + let j = 0; + while (i < remoteMigrations.length && j < localMigrations.length) { + const remote = remoteMigrations[i]!; + const filename = baseName(localMigrations[j]!); + // ListLocalMigrations guarantees a match, so the capture group is present. + const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; + if (remote === local) { + i++; + j++; + } else if (remote < local) { + missing.push(remote); + i++; + } else { + // Include out-of-order local migrations. + unapplied.push(localMigrations[j]!); + j++; + } + } + // Ensure all remote versions exist on local. + if (j === localMigrations.length) { + missing.push(...remoteMigrations.slice(i)); + } + if (missing.length > 0) { + return { kind: "missing-local", versions: missing }; + } + // Enforce migrations are applied in chronological order by default. + if (unapplied.length > 0) { + return { kind: "missing-remote", paths: unapplied }; + } + return { kind: "ok", pending: localMigrations.slice(remoteMigrations.length) }; +} + +/** + * Computes the `--include-all` pending set when reconciliation reports + * `missing-remote`. Mirrors Go's `GetPendingMigrations` includeAll branch + * (`internal/migration/up/up.go:46-48`): the out-of-order paths first, then the + * local migrations beyond `len(remote)+len(diff)`. + */ +export function legacyIncludeAllPending( + localMigrations: ReadonlyArray, + remoteCount: number, + diff: ReadonlyArray, +): ReadonlyArray { + return [...diff, ...localMigrations.slice(remoteCount + diff.length)]; +} + +/** + * Go's `suggestRevertHistory` (`internal/migration/up/up.go:55-61`). `fmt.Sprintln` + * appends a trailing newline to each line, so the suggestion ends with `\n`. + */ +export function legacySuggestRevertHistory(versions: ReadonlyArray): string { + return ( + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + + `${legacyBold(`supabase migration repair --status reverted ${versions.join(" ")}`)}\n` + + "\nAnd update local migrations to match remote database:\n" + + `${legacyBold("supabase db pull")}\n` + ); +} + +/** Go's `suggestIgnoreFlag` (`internal/migration/up/up.go:63-67`). */ +export function legacySuggestIgnoreFlag(paths: ReadonlyArray): string { + return ( + "\nRerun the command with --include-all flag to apply these migrations:\n" + + `${legacyBold(paths.join("\n"))}\n` + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts new file mode 100644 index 0000000000..eaf5400d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "./legacy-migration-pending.ts"; + +const local = (...versions: ReadonlyArray) => + versions.map((v) => `supabase/migrations/${v}_name.sql`); + +describe("legacyFindPendingMigrations", () => { + it("returns the local migrations beyond the remote history when in sync", () => { + const result = legacyFindPendingMigrations(local("0001", "0002", "0003"), ["0001"]); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0002_name.sql", "supabase/migrations/0003_name.sql"], + }); + }); + + it("is up to date when local and remote match exactly", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0001", "0002"]); + expect(result).toEqual({ kind: "ok", pending: [] }); + }); + + it("reports missing-local when remote has a version with no local file", () => { + const result = legacyFindPendingMigrations(local("0001", "0003"), ["0001", "0002", "0003"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-local for trailing remote versions absent locally", () => { + const result = legacyFindPendingMigrations(local("0001"), ["0001", "0002"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-remote for an out-of-order local migration", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0002"]); + expect(result).toEqual({ + kind: "missing-remote", + paths: ["supabase/migrations/0001_name.sql"], + }); + }); + + it("treats an empty remote history as all-local pending", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), []); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0001_name.sql", "supabase/migrations/0002_name.sql"], + }); + }); +}); + +describe("legacyIncludeAllPending", () => { + it("prepends the out-of-order diff then the migrations beyond remote+diff", () => { + const locals = local("0001", "0002", "0003"); + const diff = ["supabase/migrations/0001_name.sql"]; + // remoteCount 1, diff length 1 → slice from index 2. + expect(legacyIncludeAllPending(locals, 1, diff)).toEqual([ + "supabase/migrations/0001_name.sql", + "supabase/migrations/0003_name.sql", + ]); + }); +}); + +describe("suggestion strings", () => { + it("builds the revert-history suggestion with a trailing newline per line", () => { + expect(legacySuggestRevertHistory(["0002", "0003"])).toContain( + "supabase migration repair --status reverted 0002 0003", + ); + expect(legacySuggestRevertHistory(["0002"])).toMatch(/\n$/u); + expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); + }); + + it("builds the include-all suggestion listing each path on its own line", () => { + const suggestion = legacySuggestIgnoreFlag([ + "supabase/migrations/0001_a.sql", + "supabase/migrations/0002_b.sql", + ]); + expect(suggestion).toContain("--include-all"); + expect(suggestion).toContain("supabase/migrations/0001_a.sql\nsupabase/migrations/0002_b.sql"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 634c4c7b56..6a52434c89 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -209,9 +209,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( })(), ) .trim(); - // Only a missing container means "not running" → start it. Any other - // inspect failure (e.g. Docker daemon down) propagates, matching Go. - if (!stderr.includes("No such container")) { + // Only a missing container means "not running" → start it. Docker reports + // this as either "No such container" or "No such object" (the same pair + // handled in `shared/functions/serve.ts`). Any other inspect failure (e.g. + // Docker daemon down) propagates, matching Go. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: @@ -503,6 +505,13 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); +// Intentionally NOT `LegacyGoChildExitError` (contrast `legacy-db-bootstrap.seam.layer.ts`, +// fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy +// docker/pgdelta child stderr, not a passthrough of a real Go-CLI child the user invoked +// directly — Go itself wraps every shadow-DB failure into a generic error that `cmd/root.go`'s +// `recoverAndExit` exits `1` for, so propagating THIS child's exact exit code would itself +// diverge from Go, and suppressing this message (as `LegacyGoChildExitError` does for its own, +// already-detailed child stderr) would drop the only actionable line the user sees. const failure = (exitCode?: number) => new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts new file mode 100644 index 0000000000..2617aee0c3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import { Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyCreateSeedTable } from "../../../shared/legacy-migration-history.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; + +/** + * Seed-history DML, verbatim from Go's `pkg/migration/history.go`. The schema/table + * DDL (with a transaction-scoped lock timeout) lives in `legacyCreateSeedTable`. + */ +const UPSERT_SEED_FILE = + "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; +const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; + +/** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ +export interface LegacySeedFile { + /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + readonly path: string; + /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + readonly hash: string; + /** True when the remote `seed_files` row has a different hash (re-hash only). */ + readonly dirty: boolean; +} + +const META_CHARS = /[*?[\\]/u; + +/** Result of resolving `[db.seed].sql_paths` against the workspace. */ +interface LegacyGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ + readonly files: ReadonlyArray; + /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ + readonly warning: Option.Option; +} + +/** + * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` + * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined + * under the `supabase/` directory (Go resolves `sql_paths` at config load, + * `config.go:884`). Matches per pattern are sorted; the overall result preserves + * first-seen order across patterns. A pattern that matches nothing, or is malformed + * (Go's `path.ErrBadPattern`, e.g. an unterminated `[` class), contributes a warning + * but is not fatal — mirroring `fs.Glob`'s up-front `Match(pattern, "")` validation + * (`io/fs/glob.go`) and the sibling seed pipeline's `legacy-seed.ts:resolveSeedFiles`. + */ +const legacyGlobSeedFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const seen = new Set(); + const files: Array = []; + const errors: Array = []; + + for (const rawPattern of patterns) { + // Patterns arrive already resolved to Go's config-load form (relative entries + // supabase/-joined, absolute preserved) via `legacyResolveSeedSqlPath` — the reader + // for `[db.seed].sql_paths`, the caller for `--sql-paths`. Go's `config.Glob.Files` + // globs those resolved paths without re-prefixing (`config.go:102-124`), so only + // normalize separators here; re-joining `supabase/` would double-prefix. + const pattern = toSlash(rawPattern); + // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a + // malformed glob is reported as `failed to glob files: ` and + // contributes no matches, rather than the misleading "no files matched" below. + if (legacyPathMatch(pattern, "").badPattern) { + errors.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + errors.push(`no files matched pattern: ${pattern}`); + continue; + } + for (const match of [...matches].sort()) { + const fp = toSlash(match); + // Go's `GetPendingSeeds` globs via `Glob.SQLFiles`, which `Stat`s each match: a + // directory is expanded to its regular `.sql` files recursively (`walkMatchedDir`, + // sorted) while a file match is kept verbatim (`config.go:157-183`). Without this a + // directory `sql_paths` entry (e.g. `["seeds"]`) would flow into + // `readFileString()` and fail — Go's `db push --include-seed` / remote reset + // seed the directory's SQL children instead. + const matchType = yield* fs.stat(path.isAbsolute(fp) ? fp : path.join(workdir, fp)).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "File" as const), + ); + if (matchType === "Directory") { + for (const file of yield* legacyWalkSeedSqlFiles(fs, path, workdir, fp)) { + if (!seen.has(file)) { + seen.add(file); + files.push(file); + } + } + continue; + } + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + return { + files, + warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), + } satisfies LegacyGlobResult; +}); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Splits a forward-slashed path into its directory prefix and final element. */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // Absolute patterns resolve against the filesystem root (Go preserves absolute + // seed paths); relative ones are rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); + // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). + if (!META_CHARS.test(pattern)) { + const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : resolve(d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + if (legacyPathMatch(file, name).matched) { + result.push(d === "" ? name : `${d}/${name}`); + } + } + } + return result; + }); + +/** + * Recursively collects the regular `.sql` files under a matched seed directory, porting + * Go's `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && + * filepath.Ext(path) == ".sql"`, `config.go:126-131,194-211`). Paths are workdir-relative + * (matching the glob output), forward-slashed, and sorted for deterministic application. + */ +const legacyWalkSeedSqlFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dir: string, +): Effect.Effect, never> => + Effect.gen(function* () { + const collected: Array = []; + const walk = (rel: string): Effect.Effect => + Effect.gen(function* () { + const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + const childRel = `${rel}/${name}`; + const childType = yield* fs + .stat(path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel)) + .pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => "Unknown" as const), + ); + if (childType === "Directory") { + yield* walk(childRel); + } else if (childType === "File" && childRel.endsWith(".sql")) { + collected.push(toSlash(childRel)); + } + } + }); + yield* walk(dir); + return collected.sort(); + }); + +/** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ +const readRemoteSeeds = (session: LegacyDbSession) => + session.query(SELECT_SEED_TABLE).pipe( + Effect.map((rows) => { + const applied = new Map(); + for (const row of rows) applied.set(String(row["path"]), String(row["hash"])); + return applied; + }), + Effect.catch((error: LegacyDbExecError) => + isUndefinedTable(error) ? Effect.succeed(new Map()) : Effect.fail(error), + ), + ); + +const isUndefinedTable = (error: LegacyDbExecError): boolean => + error.code !== undefined + ? error.code === "42P01" + : /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message); + +/** + * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths + * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, + * and emit each local file that is new (`dirty=false`) or hash-changed + * (`dirty=true`); files whose hash already matches are skipped. + */ +export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const output = yield* Output; + const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); + if (Option.isSome(warning)) { + yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + } + const pending: Array = []; + if (files.length === 0) return pending; + + const applied = yield* readRemoteSeeds(session); + for (const file of files) { + // Go's `NewSeedFile` hashes the raw file stream (`io.Copy`, `pkg/migration/file.go:184`), + // so hash the bytes — not a UTF-8-decoded string, which replaces invalid sequences and + // would drift from the Go-recorded `seed_files` hash for a non-UTF-8 seed (SQL_ASCII dump + // / binary COPY payload), spuriously re-running it across a Go ↔ native switch. + const content = yield* fs.readFile(path.isAbsolute(file) ? file : path.join(workdir, file)); + const hash = createHash("sha256").update(content).digest("hex"); + const appliedHash = applied.get(file); + if (appliedHash !== undefined) { + if (appliedHash === hash) continue; // Already applied, unchanged. + pending.push({ path: file, hash, dirty: true }); + continue; + } + pending.push({ path: file, hash, dirty: false }); + } + return pending; +}); + +/** + * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` + * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` + * table, then per file emit the dirty/clean status line and, in one transaction, + * run the file's statements (skipped when dirty — only the hash is refreshed) + * followed by the `seed_files` hash upsert. + */ +export const legacySeedData = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + workdir: string, + path: Path.Path, + seeds: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (seeds.length === 0) return; + // Go's `CreateSeedTable` (history.go:54-64) runs `SET lock_timeout = '4s'` + + // schema/table DDL in one implicit transaction, so a conflicting schema/table lock + // fails promptly but the timeout reverts on COMMIT and never leaks into the seed + // SQL run below. `legacyCreateSeedTable` reproduces that with BEGIN + SET LOCAL + + // DDL + COMMIT (creating the schema first so a seed-only run doesn't fail). + yield* legacyCreateSeedTable(session); + for (const seed of seeds) { + yield* output.raw( + seed.dirty + ? `Updating seed hash to ${seed.path}...\n` + : `Seeding data from ${seed.path}...\n`, + "stderr", + ); + // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) + // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed + // that is unreadable or contains malformed SQL still fails and leaves the + // previous hash — only the queueing of statements is gated on `Dirty`. + const lines = legacySplitAndTrim( + yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), + ), + ); + const statements = seed.dirty ? [] : lines; + yield* session.exec("BEGIN"); + const body = Effect.gen(function* () { + for (const statement of statements) yield* session.exec(statement); + yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); + yield* session.exec("COMMIT"); + }); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + } + }).pipe( + Effect.mapError((error) => + mapError( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : String(error), + ), + ), + ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts new file mode 100644 index 0000000000..6f0a1dcb3f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "./legacy-seed-ops.ts"; + +class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} + +function fakeSeedSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => { + calls.push({ kind: "exec", sql }); + return Effect.void; + }, + query: (sql) => { + calls.push({ kind: "query", sql }); + return Effect.succeed([]); + }, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +// Glob matching itself is `legacyPathMatch` (`../../../shared/legacy-path-match.ts`), +// a faithful port of Go's `path.Match` already covered by +// `legacy-path-match.unit.test.ts` (including the `^`-only negation / `!`-is-literal +// rule this file used to duplicate — and get wrong — in a local `legacyMatchPattern`). +// This exercises that the seed pipeline's own glob resolution (`legacyGetPendingSeeds`) +// actually uses it end to end, per Go's `config.Glob.Files` → `fs.Glob` → `path.Match`. +describe("legacyGetPendingSeeds (glob character classes)", () => { + it.effect( + "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + writeFileSync(join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Go's `[!a]` is a positive class of the literal members `!` and `a` — only a + // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old + // shell-style bug negated on `!` too, and would have matched `b.sql` instead). + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["[!a].sql"], dir); + expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", + () => { + // An unclosed `[` is malformed per Go's `path.Match` grammar (`ErrBadPattern`), which + // `fs.Glob` reports as `failed to glob files: syntax error in pattern` — not the + // generic `no files matched pattern` a same-shaped but well-formed glob would get. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); + const { session } = fakeSeedSession(); + const out = mockOutput({ format: "text" }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["seed[.sql"], dir); + expect(pending).toEqual([]); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "failed to glob files: syntax error in pattern", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(out.layer), Effect.provide(BunServices.layer)); + }, + ); +}); + +const runSeed = ( + session: LegacyDbSession, + workdir: string, + seeds: ReadonlyArray<{ readonly path: string; readonly hash: string; readonly dirty: boolean }>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySeedData( + session, + fs, + workdir, + path, + seeds, + (message) => new TestError({ message }), + ); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + +describe("legacySeedData (dirty parse)", () => { + it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { + // Go's `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the + // dirty check, so a dirty seed pointing at a missing file must fail (and leave + // the previous hash) rather than silently upserting the new hash. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + // The hash upsert is a `query`; the only execs that ran are the + // schema/table creation (whose DDL also mentions `seed_files`), so assert + // no `query` ran rather than substring-matching the table name. + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( + Effect.tap(() => + Effect.sync(() => { + // Go's CreateSeedTable scopes the lock timeout to the DDL transaction + // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + // Statements are NOT executed for a dirty seed, but the hash IS upserted. + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c980a749c..dacbedff73 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,17 +1,35 @@ # `supabase db start` +Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler +validates config, checks whether the local Postgres container is already running, +and otherwise delegates the container bootstrap to the bundled Go binary's hidden +`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not +ported). This is `db start`, **not** the top-level `supabase start`: no status +table, no `cli_stack_started` event, no `Finished` line. + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | -| `` (from `--from-backup`) | binary | when `--from-backup` flag is set | +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | +| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | +| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | +| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | + +## Subprocesses + +| Command | When | Purpose | +| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | + +`--network-id` and a flag-selected `--profile` are forwarded to the seam. ## API Routes @@ -19,34 +37,49 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +(The Go seam may call Auth's JWKS endpoint while applying service migrations on a +fresh PG15 volume; that is internal to the seam, not the TS handler.) + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------- | ---------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------ | -| `0` | success | -| `1` | Docker not running | -| `1` | database container start error | +| Code | Condition | +| -------------------- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| child's exact code\* | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | + +\* The `db __db-bootstrap` seam propagates the spawned `supabase-go` child's +real exit code (e.g. `130` after a Ctrl-C mid-bootstrap) instead of collapsing +every failure to `1` — in every `--output-format` (CLI-1879). ## Output ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as the local Postgres container starts. +- Already running → `Postgres database is already running.` on **stderr**, exit 0. +- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to + **stderr**. No stdout output, no `Finished` line. ### `--output-format json` -Not applicable. +Emits a single result object to stdout: `{ status: "already-running" }` or +`{ status: "started" }`. Progress stays on stderr. ### `--output-format stream-json` -Not applicable. +Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start. +- `--from-backup` restores the database from a logical backup file on start; the + health check is skipped for backups (a large restore can exceed the timeout). +- No `cli_stack_started` telemetry — that event belongs to `supabase start`, not + `db start`. The only event is the standard `cli_command_executed`. diff --git a/apps/cli/src/legacy/commands/db/start/start.command.ts b/apps/cli/src/legacy/commands/db/start/start.command.ts index cc4081ae84..c9457733ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.command.ts +++ b/apps/cli/src/legacy/commands/db/start/start.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbStart } from "./start.handler.ts"; +import { legacyDbStartRuntimeLayer } from "./start.layers.ts"; const config = { fromBackup: Flag.string("from-backup").pipe( @@ -14,5 +18,15 @@ export type LegacyDbStartFlags = CliCommand.Command.Config.Infer; export const legacyDbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), - Command.withHandler((flags) => legacyDbStart(flags)), + Command.withHandler((flags) => + legacyDbStart(flags).pipe( + withLegacyCommandInstrumentation({ + // `--from-backup` is not telemetry-safe in Go (no markFlagTelemetrySafe), + // so a set value reaches telemetry as ``. + flags: { "from-backup": flags.fromBackup }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbStartRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index a1aa3e586a..7428173f1a 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,10 +1,85 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +/** + * `supabase db start` — start the local Postgres database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS + * orchestrates: it validates config, checks whether the database is already + * running (printing Go's "already running" line), and otherwise delegates the + * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + + * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does + * NOT print a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. + */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "start"]; - if (Option.isSome(flags.fromBackup)) args.push("--from-backup", flags.fromBackup.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const seam = yield* LegacyDbBootstrapSeam; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + + const body = Effect.gen(function* () { + // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` + // (`internal/db/start/start.go:45`): a missing config is tolerated (defaults), but + // a present config that is malformed, references an undecryptable `encrypted:` + // secret, or fails Validate aborts before any container work. `legacyCheckDbToml` + // is that exact load+validate — call it here (not via the seam's best-effort read, + // which swallows config errors) so `db start` fails fast on a broken config. + yield* legacyCheckDbToml(fs, path, cliConfig.workdir); + + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to + // stderr and return nil (exit 0). + const running = yield* seam.isDbRunning(); + if (running) { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + + // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on + // failure). The seam tees "Starting database...", "Initialising schema...", + // etc. to stderr. + // + // Resolve a relative `--from-backup` against the CALLER's cwd, mirroring Go's + // `StartDatabase` (`filepath.Join(utils.CurrentDirAbs, fromBackup)`, start.go:160-161) + // where `CurrentDirAbs` is captured before `ChangeWorkDir`. The seam spawns the Go child + // with cwd = the project workdir, so passing a relative path would resolve it against the + // project root (wrong file / not found) when `db start` runs from a subdirectory or with + // `--workdir`. Passing an absolute path makes the child's resolution a no-op. + const fromBackupFlag = Option.getOrUndefined(flags.fromBackup); + // An empty `--from-backup ""` is a normal no-backup start in Go (`len(fromBackup) == 0`), + // so treat it as absent rather than joining it to a directory path. + const fromBackup = + fromBackupFlag === undefined || fromBackupFlag === "" + ? undefined + : path.isAbsolute(fromBackupFlag) + ? fromBackupFlag + : path.join(runtimeInfo.cwd, fromBackupFlag); + yield* seam.startDatabase({ fromBackup }); + + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + }); + + // db start is local-only — no project ref, so no linked-project cache write. + // Telemetry still flushes on success and failure (Go's PersistentPostRun). + yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts new file mode 100644 index 0000000000..e53edc5f2b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -0,0 +1,286 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyDbStart } from "./start.handler.ts"; +import type { LegacyDbStartFlags } from "./start.command.ts"; + +const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective + * call fail (Docker daemon down / StartDatabase error). `startExitCode`, when set, + * fails `startDatabase` with a `LegacyGoChildExitError` carrying that code instead — + * simulating the seam's real bootstrap child (`db __db-bootstrap --mode start`) + * exiting non-zero (CLI-1879). Records the args passed to `startDatabase`. + */ +function mockSeam( + opts: { + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + startExitCode?: number; + } = {}, +) { + const startCalls: Array<{ fromBackup?: string }> = []; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => + opts.runningFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) + : Effect.succeed(opts.running ?? false), + startDatabase: (args: { fromBackup?: string }) => { + if (opts.startExitCode !== undefined) { + return Effect.fail( + new LegacyGoChildExitError({ + exitCode: opts.startExitCode, + message: `failed to bootstrap the local database: exit ${opts.startExitCode}`, + }), + ); + } + return opts.startFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) + : Effect.sync(() => { + startCalls.push(args); + }); + }, + recreateDatabase: () => Effect.void, + awaitStorageReady: () => Effect.succeed(false), + }); + return { + layer, + get startCalls() { + return startCalls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + format?: OutputFormat; + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + startExitCode?: number; + /** Caller cwd (Go's `CurrentDirAbs`) for relative `--from-backup` resolution. */ + cwd?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const seam = mockSeam(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + seam.layer, + mockLegacyCliConfig({ workdir }), + telemetry.layer, + mockRuntimeInfo({ cwd: opts.cwd ?? workdir }), + BunServices.layer, + ); + return { layer, out, seam, telemetry }; +} + +describe("legacy db start", () => { + const tmp = useLegacyTempWorkdir("supabase-db-start-"); + + it.live("reports an already-running database without starting a container", () => { + const { layer, out, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("starts the database when it is not running", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + // db start prints no "Finished" line and no status table. + expect(out.stderrText).not.toContain("Finished"); + }); + }); + + it.live("forwards an absolute --from-backup to the bootstrap seam unchanged", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( + Effect.provide(layer), + ); + expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); + }); + }); + + it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { + // Go resolves a relative fromBackup against `CurrentDirAbs` (the caller cwd, captured + // before ChangeWorkDir), so the seam must receive the caller-relative absolute path even + // though its Go child runs with cwd = the project workdir. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("dump.sql") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: "/caller/here/dump.sql" }]); + }); + }); + + it.live("treats an empty --from-backup as a normal no-backup start", () => { + // Go's StartDatabase sees `len(fromBackup) == 0` and starts without a backup; an empty + // string must not be joined to the caller cwd and passed as a directory path. + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + cwd: "/caller/here", + }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("") }).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + }); + }); + + it.live("proceeds with no config file (missing config is tolerated)", () => { + const { layer, seam } = setup(tmp.current, { running: false }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + }); + }); + + it.live("fails fast on a malformed config.toml", () => { + const { layer, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + // No container work attempted; telemetry still flushes on failure. + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("fails fast on an undecryptable secret even when the db is already running", () => { + // Regression for the seam swallowing config-load errors: Go runs `flags.LoadConfig` + // (which decrypts every secret) BEFORE `AssertSupabaseDbIsRunning`, so a broken + // config aborts `db start` regardless of container state. Previously the handler's + // only config read was the seam's best-effort one, so an undecryptable secret with + // the container already up printed "already running" and exited 0. + const { layer, out } = setup(tmp.current, { + toml: '[db]\nroot_key = "encrypted:anything"\n', + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + } + expect(out.stderrText).not.toContain("already running"); + }); + }); + + it.live("propagates a Docker inspect failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to inspect service"); + } + }); + }); + + it.live("propagates a StartDatabase failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to bootstrap"); + } + }); + }); + + it.live( + "propagates the bootstrap child's exact exit code as LegacyGoChildExitError and still flushes telemetry", + () => { + // The bootstrap seam's `startDatabase` (the `!captureStdout` bootstrap-child + // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` + // it fails with — not a generic `LegacyDbBootstrapError` collapsing every exit code to + // 1 — and the handler's own `Effect.ensuring(telemetryState.flush)` finalizer must + // still run despite the typed failure (CLI-1879). + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + startExitCode: 3, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + expect(telemetry.flushed).toBe(true); + }); + }, + ); + + it.live("emits a json result when the database is already running", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("already-running"); + }); + }); + + it.live("emits a json result after starting the database", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("started"); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts new file mode 100644 index 0000000000..71603292ee --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db start`. The command is local-only, so it needs + * far less than the remote-capable db commands: just the container-bootstrap seam + * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry + * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, + * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, + * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is + * provided to the seam explicitly (legacy CLAUDE.md rule 5). + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbStartRuntimeLayer = Layer.mergeAll( + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "start"]), +); diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 90660186db..5dada1097d 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -6,10 +6,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ----------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written @@ -92,7 +92,7 @@ suppressed on stderr. `delete` ignores `-o`. - `--custom-hostname` is required for `create`. - `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` → `PROJECT_ID` env → linked-project file, matching Go. +- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file, matching Go. - The project-ref fallback env var is `SUPABASE_PROJECT_ID`, matching Go (Go calls `viper.GetString("PROJECT_ID")` under `viper.SetEnvPrefix("SUPABASE")`, which resolves to the `SUPABASE_PROJECT_ID` environment variable). - **Documented divergences from Go (intentional):** - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. diff --git a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md index 458b7aeb86..770358b237 100644 --- a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md @@ -6,11 +6,11 @@ additionally reads the new key from stdin. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `PROJECT_ID` unset, to resolve linked ref | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `SUPABASE_PROJECT_ID` unset, to resolve linked ref | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written @@ -30,12 +30,11 @@ additionally reads the new key from stdin. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` / `PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes @@ -76,7 +75,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`/`PROJECT_ID`, or a linked project. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or whitespace-only key sends an empty `root_key`, matching Go's `io.Copy` + diff --git a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md index f3914b1b22..d46ffc8b86 100644 --- a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md @@ -2,28 +2,39 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | ----------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | ## Exit Codes @@ -34,6 +45,12 @@ | `1` | authentication error (no token found) | | `1` | network / connection failure | +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`project-ref` recorded verbatim, matching `functions list`; every other flag redacted) | + ## Output ### `--output-format text` (Go CLI compatible) diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts index c7fa7bec59..307d65aa3c 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -17,6 +18,14 @@ const config = { export type LegacyFunctionsDeleteFlags = CliCommand.Command.Config.Infer; +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFunctionsDeleteHandler = (flags: LegacyFunctionsDeleteFlags) => + legacyFunctionsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withJsonErrorHandling, + ); + export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe( Command.withDescription( "Delete a Function from the linked Supabase project. This does NOT remove the Function locally.", @@ -32,11 +41,6 @@ export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a deployed function from a specific project", }, ]), - Command.withHandler((flags) => - legacyFunctionsDelete(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), - ), + Command.withHandler(legacyFunctionsDeleteHandler), Command.provide(legacyManagementApiRuntimeLayer(["functions", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts index c3d78584d3..6c897b64db 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Layer, Option, Stdio } from "effect"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -10,10 +13,34 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { legacyFunctionsDeleteHandler } from "./delete.command.ts"; import { legacyFunctionsDelete } from "./delete.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-"); +// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through +// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors +// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. +// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't +// merge this context (most callers don't need it). +function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} + describe("legacy functions delete", () => { it.live("deletes a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -68,4 +95,41 @@ describe("legacy functions delete", () => { expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); }).pipe(Effect.provide(layer)); }); + + it.live( + "does not redact --project-ref in cli_command_executed (Go parity: cmd/functions.go:153)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + analytics, + }), + commandRuntimeLayer(["functions", "delete"]), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "delete", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDeleteHandler({ + functionName: "hello-world", + projectRef: Option.some("abcdefghijklmnopqrst"), + }); + + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts index 495cb4f3bc..cb5577e056 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -64,7 +65,7 @@ export const legacyFunctionsDeployCommand = Command.make("deploy", config).pipe( ]), Command.withHandler((flags) => legacyFunctionsDeploy(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts index 5f8df92de9..b9806a8151 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts @@ -4,21 +4,25 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; -// Argument-validation negatives for `functions deploy`. This validation lives in -// the Go CLI today (the legacy TS command proxies to it); a black-box subprocess -// test keeps these assertions valid through the eventual native TS port — it -// guards behavior, not implementation. Asserting the SPECIFIC error text also -// avoids a false pass from an unrelated non-zero exit (e.g. a missing Go binary). +// Argument-validation negatives for `functions deploy`. Both checks below are +// native TS today (deployFunctions in shared/functions/deploy.ts) — the +// bundler-mutex message byte-matches cobra's validateExclusiveFlagGroups +// template, and --jobs mirrors Go's own top-of-RunE guard (`if useApi { ... } +// else if maxJobs > 1 { error }`). A black-box subprocess test still earns its +// keep here: asserting the SPECIFIC error text avoids a false pass from an +// unrelated non-zero exit (e.g. a missing build artifact), and exercises the +// real CLI entrypoint end to end. // -// All cases fail before any network call (cobra flag parsing / pre-resolution), -// so no auth or linked project is required. +// All cases fail before any network call (flag-group validation / the jobs +// check both run before project-ref resolution), so no auth or linked +// project is required. const E2E_TIMEOUT_MS = 30_000; const SLUG = "deploy-e2e-basic"; // Valid-format token + ref to clear the auth and project-ref gates (both checked -// before the Go bundler-flag validation under test). These cases all fail before -// any network call (cobra flag-group validation / the jobs check at the top of -// RunE), so neither value is ever used against a real API. +// before the bundler-flag validation under test). These cases all fail before +// any network call (flag-group validation / the jobs check at the top of the +// handler), so neither value is ever used against a real API. const FAKE_TOKEN = `sbp_${"0".repeat(40)}`; const FAKE_REF = "a".repeat(20); @@ -41,7 +45,10 @@ describe("supabase functions deploy (legacy) — argument validation", () => { }, ); expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/none of the others can be|mutually exclusive/i); + // Byte-matches cobra's validateExclusiveFlagGroups (flag_groups.go:204). + expect(stderr).toContain( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be", + ); }); } @@ -56,12 +63,36 @@ describe("supabase functions deploy (legacy) — argument validation", () => { }, ); expect(exitCode).not.toBe(0); - // The Go CLI phrases this as either "must be used together with --use-api" - // or "cannot be used with local bundling" depending on version — both mean - // --jobs is rejected without server-side (--use-api) bundling. - expect(stderr).toMatch(/--jobs\b.*(--use-api|local bundling)/i); + expect(stderr).toContain("--jobs must be used together with --use-api"); }); + test( + "rejects --jobs without --use-api even with --use-docker=false (Go parity gap)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + [ + "functions", + "deploy", + SLUG, + "--project-ref", + FAKE_REF, + "--use-docker=false", + "--jobs", + "2", + ], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("--jobs must be used together with --use-api"); + }, + ); + test("fails without a linked project or --project-ref", { timeout: E2E_TIMEOUT_MS }, async () => { using home = makeTempHome(); const workdir = mkdtempSync(join(tmpdir(), "fn-deploy-nolink-")); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index ece00daaa3..f1f8e6c4c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -41,6 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), + goViperCompat: true, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 7984803387..e572727916 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -14,6 +14,8 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { ConflictingFunctionDeployFlagsError } from "../../../../shared/functions/deploy.errors.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; import type { LegacyFunctionsDeployFlags } from "./deploy.command.ts"; @@ -482,4 +484,285 @@ describe("legacy functions deploy", () => { ), ); }); + + it.live("rejects the bundler mutex with cobra's exact error text", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--use-docker"]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(ConflictingFunctionDeployFlagsError); + if (!(error instanceof ConflictingFunctionDeployFlagsError)) { + throw new Error(`unexpected error: ${String(error)}`); + } + expect(error.message).toBe( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be; [use-api use-docker] were all set", + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + describe("--jobs validation (Go parity: cmd/functions.go:79-82)", () => { + function setupJobsTest(rawArgs: ReadonlyArray) { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => Effect.succeed(legacyJsonResponse(request, 200, [])), + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ args: Effect.succeed(rawArgs) }), + ); + return { out, api, layer }; + } + + it.live("rejects --jobs > 1 without --use-api, even with default --use-docker", () => { + const { layer } = setupJobsTest(["functions", "deploy", "hello-world", "--jobs", "2"]); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: true, + jobs: Option.some(2), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("--jobs must be used together with --use-api"); + }); + }); + + it.live("rejects --jobs > 1 with --use-docker=false and no --use-api (Go parity gap)", () => { + // Divergence this test guards: previously the guard only fired when local + // bundling (Docker/legacy-bundle) was active, so `--use-docker=false --jobs 2` + // (no --use-api) silently passed in TS while Go rejected it. + const { layer } = setupJobsTest([ + "functions", + "deploy", + "hello-world", + "--use-docker=false", + "--jobs", + "2", + ]); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: false, + jobs: Option.some(2), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("--jobs must be used together with --use-api"); + }); + }); + + it.live("allows --jobs > 1 together with --use-api", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: true, + jobs: Option.some(2), + }); + + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + + it.live("treats --jobs 0 as 1 and does not require --use-api", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--jobs", "0"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + jobs: Option.some(0), + }); + + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); + + describe("bundler routing with --use-api=false (Go parity: cmd/functions.go:79-80)", () => { + it.live("falls through to Docker bundling, not the API path, when --use-api=false", () => { + // Divergence this test guards: Go's `if useApi { useDocker = false }` only forces + // the API path when the RESOLVED value is true. `--use-api=false` alone must leave + // `useDocker`'s own value (default true) in effect, routing to Docker — previously + // `useLocalBundler` keyed off flag *presence* (`explicitUseApi`), so typing + // `--use-api=false` silently forced the API path instead. + const out = mockOutput({ format: "text" }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + }), + ); + }, + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ + ...baseFlags, + useApi: false, + useDocker: true, + }); + + // Docker was actually attempted (proves useLocalBundler resolved to true); + // it wasn't running, so the command fell back to the API and still succeeded. + expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + expect(out.stdoutText).toContain( + "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 7a70bcb517..9c9c6a1845 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,45 +2,72 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | ---------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API | +| Path | Format | When | +| --------------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------ | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ----------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses -| Command | When | Purpose | -| ------------------------------------ | ----------------------------------- | ----------------------------------- | -| `supabase-go functions download ...` | `--use-docker` or `--legacy-bundle` | preserve hidden compatibility modes | +| Command | When | Purpose | +| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------- | +| `supabase-go functions download ...` | `--use-docker` (default) or `--legacy-bundle`, unless `--use-api` | preserve hidden compatibility modes | + +The delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's +own `cli_command_executed` doesn't double-count on top of this command's own +telemetry (mirrors `db pull`/`db diff`'s delegated-call pattern). In +`--output-format json|stream-json`, the child's stdout is captured and +discarded instead of inherited (`LegacyGoProxy.execCapture`) — the raw text +never reaches the terminal, and this command emits the `Output` envelope +itself once the child exits successfully. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | network / connection failure | +| Code | Condition | +| ---- | -------------------------------------- | +| `0` | success | +| `1` | API error (non-2xx response) | +| `1` | authentication error (no token found) | +| `1` | network / connection failure | +| `1` | invalid function slug or flag conflict | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`project-ref` recorded verbatim, matching `functions list`/`delete`; `use-api`/`use-docker`/`legacy-bundle` also recorded verbatim since they are boolean flags, matching Go's `isBooleanFlag` branch; no flag on this command is currently redacted) | ## Output @@ -50,11 +77,13 @@ Prints progress and success messages as functions are downloaded. ### `--output-format json` -Prints a structured success result with the downloaded function slugs and project ref. +Prints a structured success result with the downloaded function slugs and project ref. On the +Docker/legacy-bundle proxy path, the Go child's stdout is captured/discarded (never inherited) so +it can't corrupt the envelope; the slug list is resolved independently for the payload. ### `--output-format stream-json` -Prints a structured success result with the downloaded function slugs and project ref. +Same envelope as `json` above (including on the proxy path). ## Notes @@ -62,4 +91,7 @@ Prints a structured success result with the downloaded function slugs and projec - Requires a linked project (`--project-ref` or linked project config). - Native downloads reject path traversal and symlink escapes before writing source files. - `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`. +- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` proxies to the Go binary's Docker-based unbundler unless `--use-api` resolves to `true`, which forces the native server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = false }` reads the resolved flag value, not presence — `--use-api=false` still proxies). +- If Docker is not running, the Go binary itself prints `WARNING: Docker is not running` to stderr and falls back to its own server-side unbundler — the command still exits `0` without Docker installed or running. +- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. The Go proxy call itself also only ever forwards one of `--use-docker`/`--legacy-bundle`, never both, even though `--use-docker` defaults to `true`. - Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index 0d3f559dd0..e7cb3d5ad5 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -1,5 +1,6 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -19,6 +20,7 @@ const config = { ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions locally."), + Flag.withDefault(true), Flag.withHidden, ), legacyBundle: Flag.boolean("legacy-bundle").pipe( @@ -29,6 +31,14 @@ const config = { export type LegacyFunctionsDownloadFlags = CliCommand.Command.Config.Infer; +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFunctionsDownloadHandler = (flags: LegacyFunctionsDownloadFlags) => + legacyFunctionsDownload(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withJsonErrorHandling, + ); + export const legacyFunctionsDownloadCommand = Command.make("download", config).pipe( Command.withDescription( "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", @@ -44,11 +54,6 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p description: "Download all functions from a specific project", }, ]), - Command.withHandler((flags) => - legacyFunctionsDownload(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), - ), + Command.withHandler(legacyFunctionsDownloadHandler), Command.provide(legacyManagementApiRuntimeLayer(["functions", "download"])), ); diff --git a/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts new file mode 100644 index 0000000000..f52a23d3be --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +// Argument-validation negatives for `functions download`. This validation +// lives in the Go CLI today (the legacy TS command proxies to it); a +// black-box subprocess test keeps these assertions valid through the +// eventual native TS port — it guards behavior, not implementation. Mirrors +// `deploy.e2e.test.ts`'s coverage for the sibling `--use-docker` default bug +// (CLI-1862). +// +// The mutex-conflict cases below fail before any network call (flag parsing +// / mutex validation), so no auth or linked project is required. + +const E2E_TIMEOUT_MS = 30_000; +const SLUG = "download-e2e-basic"; +const FAKE_TOKEN = `sbp_${"0".repeat(40)}`; +const FAKE_REF = "a".repeat(20); + +describe("supabase functions download (legacy) — argument validation", () => { + const conflicts = [ + { name: "--use-api + --use-docker", flags: ["--use-api", "--use-docker"] }, + { name: "--use-api + --legacy-bundle", flags: ["--use-api", "--legacy-bundle"] }, + { name: "--use-docker + --legacy-bundle", flags: ["--use-docker", "--legacy-bundle"] }, + ] as const; + + for (const { name, flags } of conflicts) { + test(`rejects ${name} as mutually exclusive`, { timeout: E2E_TIMEOUT_MS }, async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + ["functions", "download", SLUG, "--project-ref", FAKE_REF, ...flags], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/none of the others can be|mutually exclusive/i); + }); + } + + // CLI-1862: `--use-docker` now defaults to `true` (Go parity). Before the + // fix, that default was counted as "explicitly selected" by the mutex + // check, so passing `--use-api` alone was incorrectly rejected as + // conflicting with the (unpassed) `--use-docker` default. Covered in + // `download.integration.test.ts` ("does not treat the --use-docker default + // as conflicting with an explicit --use-api") via a mocked platform API + // instead of here: now that the mutex check is fixed, `--use-api` alone + // passes validation and proceeds to the native downloader, which calls the + // real Management API with the fake token/ref — an argument-validation + // test shouldn't depend on that network round-trip. + + // CLI-1862: the TS→Go proxy call must not forward the now-defaulted + // `--use-docker` alongside an explicit `--legacy-bundle` — the Go binary + // re-parses this argv itself and enforces the same mutual exclusivity, so + // forwarding both breaks `--legacy-bundle` outright. Covered in + // `download.integration.test.ts` ("forwards only --legacy-bundle to the Go + // proxy...") via a mocked `LegacyGoProxy` instead of here: unlike + // `--use-api`, `--legacy-bundle` routes to the Go binary's `RunLegacy` + // downloader, which calls `InstallOrUpgradeDeno` before any network call + // (`apps/cli-go/internal/functions/download/download.go`). Each e2e run + // gets a fresh `SUPABASE_HOME`, so this would trigger a real, uncached + // Deno download from GitHub on every run — a real cross-boundary + // dependency this suite shouldn't take on to prove a pure TS-side routing + // decision. +}); diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 70cb9da73c..4b666da7f6 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Stdio } from "effect"; import { downloadFunctions, makeGoProxyDownloadArgs, @@ -20,11 +20,14 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const proxy = yield* LegacyGoProxy; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; let resolvedProjectRef = Option.none(); yield* downloadFunctions(flags, { api, projectRoot: cliConfig.workdir, + rawArgs, resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => @@ -33,8 +36,23 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu }), ), ), - proxyDownload: (proxyFlags, projectRef) => - proxy.exec(makeGoProxyDownloadArgs(proxyFlags, projectRef)), + // The delegated Go binary runs its own `Execute()` and would otherwise + // fire its own `cli_command_executed` on top of this command's own + // `withLegacyCommandInstrumentation` wrapper. Suppress it so proxied + // invocations record exactly one event, matching Go (mirrors `db pull` / + // `db diff`'s delegated-call pattern). + // + // In machine-output mode the child's stdout is captured and discarded + // instead of inherited, matching `db pull`/`db diff`'s delegated-call + // pattern for the CLI-1546 "stdout is payload-only in machine mode" + // invariant — `downloadFunctions` emits the `Output` envelope itself. + proxyDownload: (proxyFlags, projectRef, captureOutput) => { + const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + return captureOutput + ? Effect.asVoid(proxy.execCapture(args, { env, stdin: "ignore" })) + : proxy.exec(args, { env }); + }, }).pipe( Effect.ensuring( Effect.suspend(() => diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index a6384d4e06..af37969828 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, legacyJsonResponse, @@ -15,10 +18,35 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; +import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); + +// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through +// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors +// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. +// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't +// merge this context (most callers don't need it). +function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} const baseFlags: LegacyFunctionsDownloadFlags = { functionName: Option.some("hello-world"), projectRef: Option.none(), @@ -53,14 +81,26 @@ function multipartResponse(request: Parameters> = []; + const envs: Array | undefined> = []; + const captureCalls: Array> = []; + const captureEnvs: Array | undefined> = []; return { calls, + envs, + captureCalls, + captureEnvs, layer: Layer.succeed(LegacyGoProxy, { - exec: (args) => + exec: (args, opts) => Effect.sync(() => { calls.push([...args]); + envs.push(opts?.env); + }), + execCapture: (args, opts) => + Effect.sync(() => { + captureCalls.push([...args]); + captureEnvs.push(opts?.env); + return ""; }), - execCapture: () => Effect.succeed(""), }), }; } @@ -86,6 +126,15 @@ describe("legacy functions download", () => { telemetry: telemetry.layer, }), proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), ); return Effect.gen(function* () { @@ -108,7 +157,7 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("keeps hidden Docker compatibility mode behind the Go proxy", () => { + it.live("proxies to Docker by default (Go parity), with no flags passed", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); const proxy = mockProxy(); @@ -119,9 +168,20 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), ); return Effect.gen(function* () { + // `useDocker: true` mirrors what the CLI parser now resolves to by + // default (CLI-1862) — no `--use-docker` flag appears in argv above. yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect(api.requests).toEqual([]); @@ -135,6 +195,473 @@ describe("legacy functions download", () => { "--use-docker", ], ]); + // The delegated Go binary must not also fire its own + // `cli_command_executed` on top of this command's own instrumentation. + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not treat the --use-docker default as conflicting with an explicit --use-api", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // The CLI parser resolves `useDocker: true` here too (its default), + // even though only `--use-api` was passed explicitly. Neither the + // mutex check nor the routing decision should treat that default as + // if the user had asked for Docker. + yield* legacyFunctionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect( + yield* Effect.tryPromise(() => + readFile( + join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + "utf8", + ), + ), + ).toBe("console.log('legacy native')"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("still proxies to Docker when --use-api=false is passed explicitly", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api=false", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // Go's override is value-based (`if useApi { useDocker = false }`, + // apps/cli-go/cmd/functions.go:51-53), not presence-based. An explicit + // `--use-api=false` must not be treated like `--use-api` — it should + // leave the `--use-docker` default (true) in effect and still proxy. + yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + + expect(api.requests).toEqual([]); + expect(proxy.calls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ], + ]); + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a JSON success envelope when proxying to Docker in machine-output mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // CLI-1546: stdout is payload-only in machine mode, so the Go child's + // raw output must be captured/discarded (not inherited) and this + // command must emit the `Output` envelope itself, matching the native + // path's shape. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ], + ]); + expect(proxy.captureEnvs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { function_slugs: ["hello-world"], project_ref: "abcdefghijklmnopqrst" }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "lists remote functions before delegating when no function name is given in machine mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed( + legacyJsonResponse(request, 200, [ + { slug: "hello-world" }, + { slug: "goodbye-world" }, + ]), + ) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([ + ["functions", "download", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], + ]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + function_slugs: ["hello-world", "goodbye-world"], + project_ref: "abcdefghijklmnopqrst", + }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "reports no functions found without delegating when the project is empty in machine mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 200, [])) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // An empty project has nothing to delegate — this must match the + // native path's "No functions found." short-circuit instead of + // still invoking the Go/Docker child and reporting a misleading + // "Downloaded Edge Function source." success with an empty list. + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "No functions found.", + data: { function_slugs: [], project_ref: "abcdefghijklmnopqrst" }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails before delegating when the pre-flight function list fails in machine mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 500, { message: "unavailable" })) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // The pre-flight list failure must be reported before any download + // side effect — the delegated proxy must never be invoked (CLI-1862 + // review: a listing failure after a successful delegated download + // must not mask that success). + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards only --legacy-bundle to the Go proxy, not the --use-docker default too", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--legacy-bundle", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // `useDocker: true` mirrors the CLI parser's default (CLI-1862) even + // though only `--legacy-bundle` was passed explicitly. The Go proxy + // call must not forward both, or the Go binary's own + // MarkFlagsMutuallyExclusive rejects the combination. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true, legacyBundle: true }); + + expect(proxy.calls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", + ], + ]); + expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an invalid slug before ever reaching the Go proxy", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "../../etc", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + // `useDocker: true` is the real default (CLI-1862). Before this fix, + // slug validation only ran on the native path, so a malformed slug + // would sail past it and straight into the Go proxy argv. + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.some("../../etc"), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not redact --project-ref in cli_command_executed (Go parity: cmd/functions.go:178)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + analytics, + }), + proxy.layer, + commandRuntimeLayer(["functions", "download"]), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownloadHandler({ + ...baseFlags, + projectRef: Option.some("abcdefghijklmnopqrst"), + }); + + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects the bundler mutex with cobra's exact error text", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "download", "--use-api", "--use-docker"]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ + ...baseFlags, + useApi: true, + useDocker: true, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConflictingFunctionDownloadFlagsError); + if (!(error instanceof ConflictingFunctionDownloadFlagsError)) { + throw new Error(`unexpected error: ${String(error)}`); + } + expect(error.message).toBe( + "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be; [use-api use-docker] were all set", + ); + expect(proxy.calls).toEqual([]); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md index b6764b7dfc..f1613f83fe 100644 --- a/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/list/SIDE_EFFECTS.md @@ -34,7 +34,6 @@ | `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | | `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> current working dir) | -| ~~`SUPABASE_API_URL`~~ | **not honored** - Go parity. Use `SUPABASE_PROFILE` instead. | - | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/functions/list/list.command.ts b/apps/cli/src/legacy/commands/functions/list/list.command.ts index f411518741..4f85ebeb0b 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.command.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -29,7 +30,7 @@ export const legacyFunctionsListCommand = Command.make("list", config).pipe( ]), Command.withHandler((flags) => legacyFunctionsList(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/new/new.command.ts b/apps/cli/src/legacy/commands/functions/new/new.command.ts index 8a161adf28..5a9ef84b25 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.command.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.command.ts @@ -36,7 +36,7 @@ export const legacyFunctionsNewCommand = Command.make("new", config).pipe( Command.withShortDescription("Create a new Function locally"), Command.withHandler((flags) => legacyFunctionsNew(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index b448c0307a..5080e4416b 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -89,7 +89,9 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) }); const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const loaded = yield* loadProjectConfig(workdir, { goViperCompat: true }).pipe( + Effect.orElseSucceed(() => null), + ); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 1e3cd4ea5e..83992053b6 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -54,7 +54,7 @@ export const legacyFunctionsServeCommand = Command.make("serve", config).pipe( Command.withShortDescription("Serve all Functions locally"), Command.withHandler((flags) => legacyFunctionsServe(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 0718862e64..86b51f387b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -33,5 +33,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function debug, networkId, projectIdOverride: cliConfig.projectId, + goViperCompat: true, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 370af5aacf..8e3354fe2d 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1695,7 +1695,7 @@ describe("legacy functions serve integration", () => { "verify_jwt = true", "", "[remotes.override]", - 'project_id = "override-project"', + 'project_id = "overrideprojectaaaaa"', "", "[remotes.override.functions.hello]", "verify_jwt = false", @@ -1710,7 +1710,7 @@ describe("legacy functions serve integration", () => { const { layer } = setupServe({ childSpawner, - projectId: Option.some("override-project"), + projectId: Option.some("overrideprojectaaaaa"), }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), @@ -1724,20 +1724,20 @@ describe("legacy functions serve integration", () => { expect(deployMockState.volumeCalls).toEqual([ { - volumeName: "supabase_edge_runtime_override-project", - projectId: "override-project", + volumeName: "supabase_edge_runtime_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.networkCalls).toEqual([ { - networkMode: "supabase_network_override-project", - projectId: "override-project", + networkMode: "supabase_network_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.runCalls).toContainEqual( expect.objectContaining({ command: "docker", - args: ["container", "inspect", "supabase_db_override-project"], + args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], }), ); diff --git a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md index 325e8d58ed..00f28f8924 100644 --- a/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/keys/SIDE_EFFECTS.md @@ -20,10 +20,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md index 8cf887643d..f7dab774da 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------ | -| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | -| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | -| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| Path | Format | When | +| ----------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------- | +| `supabase/config.toml` / `supabase/config.json` | TOML / JSON | always when present in the active workdir; used to discover `auth.signing_keys_path` | +| `` | JSON array of JWKs | when `auth.signing_keys_path` is configured; loaded before overwrite or append | +| git ignore rules | git metadata / ignore files | best-effort after a successful write when the resulting file contains exactly 1 key | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `flags.LoadConfig`/`loadNestedEnv`) | ## Files Written @@ -22,9 +23,9 @@ ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| - | - | - | +| Variable | Purpose | Required? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). Read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878). | No | ## Exit Codes @@ -47,16 +48,17 @@ ### `--output-format json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Not applicable to output rendering; the command uses raw stdout and stderr text like the Go CLI. It does, however, affect the overwrite-confirmation prompt: since this command has no structured json/stream-json payload, requesting a non-text format from a real interactive terminal (no `--yes`, no piped stdin) fails the overwrite closed (`context canceled`) rather than silently defaulting to yes on a destructive, irreversible action. A non-TTY caller (piped or not) is unaffected — piped `y`/`n` answers are honored regardless of `--output-format`. ### `--output-format stream-json` -Not applicable; the command uses raw stdout and stderr text like the Go CLI. +Same as `--output-format json` above. ## Notes - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. +- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.[.local]` files, shell wins) and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var, resolved after `flags.LoadConfig` loads the project env — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts index 35a026f682..83bc33b38d 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts @@ -3,6 +3,7 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -29,6 +30,10 @@ const legacyGenSigningKeyRuntimeLayer = Layer.mergeAll( cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["gen", "signing-key"]), + // The overwrite-confirmation prompt reads piped stdin via `legacyPromptYesNo` + // (`stdin.readLine`), same as `config push`, `seed buckets`, `storage rm`, `db pull`, + // and `logout` — all of which merge `stdinLayer` alongside their runtime layer. + stdinLayer, ); export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pipe( @@ -55,7 +60,7 @@ export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pi ]), Command.withHandler((flags) => legacyGenSigningKey(flags).pipe( - withLegacyCommandInstrumentation({ flags }), + withLegacyCommandInstrumentation({ flags, config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts new file mode 100644 index 0000000000..3a2b377ee4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary — + * `signing-key.command.ts`'s actual production runtime layer, not the mocked + * `Stdin` the integration suite provides via `Layer.succeed`. A missing + * `stdinLayer` in that composition only surfaces as a "Service not found" defect + * at this boundary (see the legacy CLAUDE.md Go Parity Checklist item 5). Per-branch + * prompt/format coverage lives in the integration suite. + */ +describe("supabase gen signing-key (legacy)", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-gen-signing-key-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync( + join(projectDir, "supabase", "config.toml"), + '[auth]\nsigning_keys_path = "./signing_keys.json"\n', + ); + writeFileSync(join(projectDir, "supabase", "signing_keys.json"), "[]\n"); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test( + "declines the overwrite on a piped 'n' without crashing or writing the file", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + expect(stderr).toContain("context canceled"); + expect(stderr).not.toContain("Service not found"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toEqual([]); + }, + ); + + test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: projectDir, + stdin: "y\n", + }); + expect(exitCode).toBe(0); + expect(stderr).toContain("JWT signing key appended to:"); + const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); + expect(JSON.parse(saved)).toHaveLength(1); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 6396c658c6..87a734da73 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -6,9 +6,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; @@ -161,7 +163,7 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd).pipe( + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacyGenSigningKeyConfigParseError({ @@ -246,25 +248,6 @@ const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: .pipe(Effect.map((exitCode) => Option.some(Number(exitCode) === 0))); }); -const confirmOverwrite = Effect.fnUntraced(function* (title: string) { - const output = yield* Output; - const tty = yield* Tty; - const yes = yield* LegacyYesFlag; - if (yes) { - yield* output.raw(`${title} [Y/n] y\n`, "stderr"); - return true; - } - if (!tty.stdinIsTty) { - yield* output.raw(`${title} [Y/n] \n`, "stderr"); - return true; - } - // In json / stream-json mode `promptConfirm` fails with NonInteractiveError; treat that as a - // declined overwrite so the command cancels cleanly instead of corrupting the machine payload. - return yield* output - .promptConfirm(title, { defaultValue: true }) - .pipe(Effect.orElseSucceed(() => false)); -}); - export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* ( flags: LegacyGenSigningKeyFlags, ) { @@ -279,6 +262,15 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const warnText = (text: string) => styleIfTty(tty.stdoutIsTty, "yellow", text); return yield* Effect.gen(function* () { + // Go's `flags.LoadConfig` (`signingkeys.go:99`) loads the project `.env` files before the + // overwrite prompt reads `viper.GetBool("YES")` (`console.PromptYesNo`, `signingkeys.go:130`), + // so a `SUPABASE_YES` set only in `supabase/.env` must auto-confirm here too. Resolved inside + // this block (not above it) so a malformed/unreadable `.env` still flushes telemetry below, + // matching Go: telemetry attaches in root's `PersistentPreRunE` (`cmd/root.go:131-155`) + // before this command's own `RunE` runs `flags.LoadConfig`, so `service.Capture` still fires + // in Go even when that load fails. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // Match Go's order: LoadConfig validates the configured signing-keys file before any key is // generated, so a broken config fails fast without doing throwaway crypto work. const signingKeysConfig = yield* loadSigningKeysConfig(cliConfig.workdir); @@ -298,9 +290,30 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const nextKeys = flags.append ? [...configured.value.existingKeys, key] : yield* Effect.gen(function* () { - const confirmed = yield* confirmOverwrite( - `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, - ); + // `legacyPromptYesNo` silently returns the default (true) for any non-text + // `--output-format`, but this command has no structured json/stream-json output + // (SIDE_EFFECTS.md) — that combination only arises from a real interactive TTY + // explicitly requesting machine output. Fail closed rather than silently + // overwriting irrecoverable key material. + const confirmed = + !yes && tty.stdinIsTty && output.format !== "text" + ? false + : yield* legacyPromptYesNo( + // `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it checks + // TTY, so a non-TTY (piped or empty) invocation under `json`/`stream-json` + // would otherwise hit that check first and return the default without + // ever reading stdin. Go's `console.PromptYesNo` + // (apps/cli-go/internal/utils/console.go:64-82) has no concept of output + // format at all — it always reads piped stdin — so a piped `y`/`n` answer + // must be honored here the same as in text mode. Present a text-shaped + // view of `output` to reach that read; `raw`/`promptConfirm` write the + // prompt to stderr under every `Output` layer, so this never touches the + // machine-readable stdout payload. + output.format === "text" ? output : { ...output, format: "text" }, + yes, + `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, + true, + ); if (!confirmed) { return yield* Effect.fail( new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 93ac85949d..95439473a8 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockAnalytics, mockOutput, mockRuntimeInfo, + mockStdin, mockTty, processEnvLayer, } from "../../../../../tests/helpers/mocks.ts"; @@ -20,6 +21,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -32,6 +34,7 @@ import { legacyGenSigningKey } from "./signing-key.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); interface SetupOptions { + readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray; @@ -39,6 +42,10 @@ interface SetupOptions { // Exit code returned by the mocked `git check-ignore` subprocess. `0` means the path is // ignored, any non-zero code means it is not. Only consumed by the gitignore-warning branch. readonly gitCheckIgnoreExitCode?: number; + // Piped (non-TTY) stdin answer for the overwrite prompt (CLI-1865). + readonly pipedAnswer?: string; + // Raw argv for `legacyResolveYes`'s explicit `--yes=false` detection. + readonly cliArgs?: ReadonlyArray; } // `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so @@ -68,7 +75,7 @@ function mockGitCheckIgnore(exitCode: number) { function setup(options: SetupOptions = {}) { const out = mockOutput({ - format: "text", + format: options.format ?? "text", interactive: options.stdinIsTty ?? false, promptConfirmResponses: options.promptConfirmResponses, }); @@ -82,6 +89,8 @@ function setup(options: SetupOptions = {}) { const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void, @@ -156,6 +165,8 @@ describe("legacy gen signing-key integration", () => { processEnvLayer({ SUPABASE_HOME: tempRoot.current }), mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ @@ -203,8 +214,38 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("overwrites the configured signing keys file and defaults to yes on non-tty", () => { - const { layer, out } = setup({ stdinIsTty: false }); + it.live( + "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", + () => { + const { layer, out } = setup({ stdinIsTty: false }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.alg).toBe("RS256"); + expect(out.stderrText).toContain("Do you want to overwrite the existing"); + expect(out.stderrText).toContain("JWT signing key appended to: "); + expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865: Go's overwrite prompt reads piped stdin even in non-TTY mode and honors an + // explicit "n" — before this fix, TS returned `true` unconditionally without reading stdin at + // all, so `echo n | supabase gen signing-key` silently overwrote instead of canceling. + it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), @@ -213,17 +254,41 @@ describe("legacy gen signing-key integration", () => { writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); - yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + expect(json).toContain("context canceled"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + // Go's non-TTY prompt echoes the piped answer back to stderr after the label. + expect(out.stderrText).toContain("[Y/n] n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("overwrites when a piped non-tty answer of 'y' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); const saved = yield* Effect.tryPromise(() => readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); const parsed = JSON.parse(saved) as ReadonlyArray>; expect(parsed).toHaveLength(1); - expect(parsed[0]?.alg).toBe("RS256"); expect(out.stderrText).toContain("Do you want to overwrite the existing"); - expect(out.stderrText).toContain("JWT signing key appended to: "); - expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); }).pipe(Effect.provide(layer)); }); @@ -440,6 +505,199 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // This command has no structured json/stream-json output (SIDE_EFFECTS.md), so a real TTY + // requesting machine output is an unsupported combination — fail closed on this destructive, + // irreversible overwrite rather than silently defaulting to yes with no prompt at all. + it.live( + "declines the overwrite without prompting on a tty when --output-format is not text", + () => { + const { layer, out } = setup({ format: "json", stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865 follow-up: `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it + // checks TTY, so a non-TTY invocation under `json`/`stream-json` must not fall into that + // early return — this command has no structured json/stream-json payload, so a piped + // answer must be honored the same as text mode. Before this fix, a piped "n" here was + // silently ignored and the file was overwritten with the default (true). + it.live("honors a piped non-tty 'n' even when --output-format is json", () => { + const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped non-tty 'y' when --output-format is stream-json", () => { + const { layer } = setup({ format: "stream-json", stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved) as ReadonlyArray).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and overwrites even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE scanning + // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` + // auto-confirms and overwrites rather than consuming the piped `n`. The handler + // resolves `yes` via `legacyResolveYes`, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live( + "auto-confirms from SUPABASE_YES in the project .env, even with a piped 'n' (CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell. Go's `flags.LoadConfig` + // (`signingkeys.go:99`) loads the project `.env` files before the overwrite prompt reads + // `viper.GetBool("YES")` (`signingkeys.go:130`), so the overwrite auto-confirms and the + // piped `n` is never consumed — same precedence as the shell-env case above. + // + // Defensively clear a shell SUPABASE_YES: this test must prove the project-.env source + // specifically, not accidentally pass because a prior test in this file left the shell + // env set (the sibling shell-env tests above save/restore theirs). + const prev = process.env["SUPABASE_YES"]; + delete process.env["SUPABASE_YES"]; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev !== undefined) process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }, + ); + + it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ + stdinIsTty: false, + pipedAnswer: "n", + cliArgs: ["gen", "signing-key", "--yes=false"], + }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { @@ -447,4 +705,28 @@ describe("legacy gen signing-key integration", () => { expect(telemetry?.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.live("flushes telemetry state even when the project .env is malformed (Codex review)", () => { + // Go attaches the telemetry service in root's `PersistentPreRunE` (cmd/root.go:131-155), + // before this command's own `RunE` runs `flags.LoadConfig` (signingkeys.go:99), so + // `service.Capture` still fires even when that project-.env load fails. The project-env + // resolution here must live inside the `Effect.ensuring(telemetryState.flush)`-wrapped + // block for the same reason — locks in that fix. + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".env"), "!=broken\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 1bc6151b77..c7accf16f1 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -59,7 +59,7 @@ default 10s pg-delta probe timeout. | Variable | Purpose | Required? | | ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | | `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts index 341e9dfa38..1e1e78921e 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.command.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts @@ -75,7 +75,7 @@ export const legacyGenTypesCommand = Command.make("types", config).pipe( ]), Command.withHandler((flags) => legacyGenTypes(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-id"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-id"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 6008a3849e..87113c6a42 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -7,6 +7,10 @@ import { LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitLongFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { @@ -79,6 +83,8 @@ function isProjectNotFound(cause: unknown) { return cause instanceof LegacyGenTypesUnexpectedStatusError && cause.status === 404; } +const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -86,11 +92,7 @@ function ensureMutuallyExclusive( if (present.length <= 1) { return Effect.void; } - return Effect.fail( - new Error( - `if any flags in the group [${group.join(" ")}] are set none of the others can be; [${present.join(" ")}] were all set`, - ), - ); + return Effect.fail(new Error(cobraMutuallyExclusiveErrorMessage(group, present))); } function forwardByteStream( @@ -177,29 +179,6 @@ function findLegacyPositionalLanguage(rawArgs: ReadonlyArray): Option.Op return Option.none(); } -function hasExplicitLongFlag(rawArgs: ReadonlyArray, flagName: string): boolean { - const commandIndex = rawArgs.findIndex( - (value, index) => value === "types" && rawArgs[index - 1] === "gen", - ); - if (commandIndex === -1) { - return false; - } - - for (let index = commandIndex + 1; index < rawArgs.length; index += 1) { - const token = rawArgs[index]; - if (token === undefined) { - return false; - } - if (token === "--") { - return false; - } - if (token === `--${flagName}` || token.startsWith(`--${flagName}=`)) { - return true; - } - } - return false; -} - export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: LegacyGenTypesFlags) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; @@ -231,7 +210,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if ( Option.isSome(legacyLang) && legacyLang.value !== "typescript" && - !hasExplicitLongFlag(rawArgs, "lang") + !hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "lang") ) { return yield* Effect.fail(new Error("use --lang flag to specify the typegen language")); } @@ -244,7 +223,10 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const swiftAccessControl = flags.swiftAccessControl; const usesPgMeta = flags.local || Option.isSome(flags.dbUrl) || flags.lang !== "typescript"; - if (hasExplicitLongFlag(rawArgs, "swift-access-control") && lang !== "swift") { + if ( + hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "swift-access-control") && + lang !== "swift" + ) { return yield* Effect.fail( new Error("--swift-access-control can only be used with --lang swift"), ); @@ -254,7 +236,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le new Error("--postgrest-v9-compat can only be used with pg-meta type generation"), ); } - if (hasExplicitLongFlag(rawArgs, "query-timeout") && !usesPgMeta) { + if (hasExplicitLongFlag(rawArgs, GEN_TYPES_COMMAND_PATH, "query-timeout") && !usesPgMeta) { if (flags.linked || Option.isSome(flags.projectId)) { return yield* Effect.fail( new Error("--query-timeout can only be used with pg-meta type generation"), @@ -266,9 +248,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => - loadProjectConfig(cliConfig.workdir, { projectRef }); + loadProjectConfig(cliConfig.workdir, { projectRef, goViperCompat: true }); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 5407d1bd2d..67015337f6 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -788,8 +788,10 @@ describe("legacy gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { + // cobra sorts the violating-flag set alphabetically (sort.Strings) — + // "linked" before "local" — regardless of check order. expect(String(exit.cause)).toContain( - "if any flags in the group [local linked project-id db-url] are set none of the others can be; [local linked] were all set", + "if any flags in the group [local linked project-id db-url] are set none of the others can be; [linked local] were all set", ); } }); diff --git a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md index f5728c86be..7bfc41e7a1 100644 --- a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md @@ -40,7 +40,7 @@ no new config reads. | ---------------------------------------------------- | --------------------------------- | --------------------------------------- | | `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | | `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | -| `PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | +| `SUPABASE_PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | | libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | ## Database Queries diff --git a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md index 3da2b98fdb..9307ddcd2a 100644 --- a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md @@ -54,12 +54,12 @@ resolve the connection (via `LegacyDbConfigResolver`). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | -| `SUPABASE_API_URL` | override Management API base URL | no | -| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | -| `SUPABASE_ENV` | selects which project `.env` files load | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | +| `SUPABASE_ENV` | selects which project `.env` files load | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md index 7da5a26931..398edd9745 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -20,9 +21,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_YES` | auto-confirm the overwrite prompt | no — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | ## Exit Codes @@ -54,8 +56,9 @@ Same structured `files` result delivered as an NDJSON `result` event. - When the migrations directory is non-empty, prompts `Do you want to overwrite existing files in supabase/migrations directory?` - (default **YES**). Declining exits non-zero (`context canceled`). `--yes` - auto-confirms; a non-interactive / machine-output run takes the default (YES). + (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or + `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / + machine-output run takes the default (YES). ## Notes diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index ccc5b69f43..6fadfea2b4 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -1,6 +1,9 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyDnsResolverFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDnsResolverFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -8,6 +11,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { legacyReadMigrationTable } from "../../../shared/legacy-migration-history.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -31,8 +35,10 @@ const runFetch = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; - const yes = yield* legacyResolveYes; // --yes OR SUPABASE_YES (Go viper AutomaticEnv, root.go:318-334). + // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at + // parse time, ahead of the root `PersistentPreRunE` (same ordering as `migration down`/ + // `repair`). if (target.setFlags.length > 1) { return yield* Effect.fail( new LegacyMigrationTargetFlagsError({ @@ -55,6 +61,14 @@ const runFetch = Effect.fnUntraced(function* ( dnsResolver, }); + // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), + // i.e. after the parse-time flag-group validation above — so a SUPABASE_YES set only in + // supabase/.env auto-confirms, but a flag conflict still surfaces before any .env read. + // Resolve --yes against the project env here, not just process.env (root.go:318-334). + // Same ordering as `migration down`/`repair`. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + // Linked fetch caches the project ref on success (Go's `PersistentPostRun`). The ref is // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, // so a declined prompt returns before it runs — matching Go (PostRun is skipped on a diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts index f0fcd50ccf..b4c408487d 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -43,6 +43,8 @@ interface SetupOpts { readonly confirm?: boolean; readonly rows?: ReadonlyArray; readonly resolveFails?: boolean; + /** Raw argv seen by `resolveLegacyDbTargetFlags` (e.g. to exercise a flag conflict). */ + readonly cliArgs?: ReadonlyArray; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -109,7 +111,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyYesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, @@ -237,6 +239,27 @@ describe("legacy migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "auto-confirms the overwrite prompt from SUPABASE_YES in the project .env (Go loadNestedEnv)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell — `fetch` defaults to + // `--linked` (Go: migration.go:161), and root's `ParseDatabaseConfig` loads the project + // `.env` files before `fetch.Run`'s overwrite prompt (root.go:118), so the overwrite + // auto-confirms with no --yes flag and no piped stdin answer (CLI-1878). + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const { layer, out } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(out.stderrText).toContain("[Y/n] y"); + expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { // Go writes the prompt to stderr and reads stdin regardless of --output (console.go), // so --output-format json must NOT silently auto-accept: the overwrite prompt fires on @@ -332,8 +355,12 @@ describe("legacy migration fetch", () => { }); it.live("reports a write failure", () => { - // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. - writeFileSync(join(tmp.current, "supabase"), "not a directory"); + // A file at /supabase/migrations makes `makeDirectory` fail. `supabase` itself + // must stay a real directory here: the handler's project-env load (CLI-1878, honoring + // Go's `loadNestedEnv`) reads `/supabase/.env*` before this mkdir, and a plain + // file at `/supabase` would make that read fail first (ENOTDIR) instead. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); @@ -362,4 +389,33 @@ describe("legacy migration fetch", () => { expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); + + it.live( + "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", + () => { + // Cobra's `MarkFlagsMutuallyExclusive` validates at parse time, ahead of the root + // `PersistentPreRunE` that runs `ParseDatabaseConfig`/`loadNestedEnv` — so a flag + // conflict must surface even when `supabase/.env` is malformed (which would abort a + // project-env load with a DIFFERENT error, `LegacyDbConfigLoadError`, if the env load + // ran first). Locks in the fix that reordered the project-env load in `fetch.handler.ts` + // to run after this flag-group check. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "!=broken\n"); + const { layer } = setup(tmp.current, { + cliArgs: ["--db-url", "postgresql://x", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch( + flags({ dbUrl: Option.some("postgresql://x") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyMigrationTargetFlagsError", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts index d00659ed76..0b6859a907 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts @@ -55,8 +55,9 @@ export const legacyMigrationRepairCommand = Command.make("repair", config).pipe( // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, - // Go records `utils.EnumFlag` values verbatim (`--status`); password stays redacted. - safeFlags: ["status"], + // --status is Flag.choice and is auto-detected as safe via `config` + // below (Go's isEnumFlag, cmd/root_analytics.go:110-116); password stays redacted. + config, aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md index f9042c22a7..edfa550b89 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ The Management API exposes this read operation as `POST .../network-bans/retriev | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md index 333bf103a1..937970c60d 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -27,8 +27,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md index 7c746dbd01..f39c04d7c9 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md index fb31633592..f30b5ed87e 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -31,8 +31,8 @@ when no `--db-allow-cidr` was supplied), matching Go's `&[]string{}` initializat | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md index 69bd23294e..97168c0975 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ This command does not call a delete endpoint. It mirrors Go: fetch current confi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md index 6582161bd1..d969c35982 100644 --- a/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md index edd0de4c4a..8cfba86318 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -28,8 +28,8 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md index 23e0b9711e..fa2e82029d 100644 --- a/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/SIDE_EFFECTS.md @@ -41,7 +41,7 @@ subcommand's own `SIDE_EFFECTS.md`. | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROJECT_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | > `DB_PASSWORD` is **not** consumed. In Go it only mirrors `--db-password` via a > viper binding for downstream local-stack use; `projects create` never reads it. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md index 79a7642c2c..b0207e720a 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Flags diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 0726aaa348..1a9d18c419 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `DB_PASSWORD` | **not consumed** — Go only mirrors `--db-password` into viper for local-stack reuse; `projects create` never reads it | n/a | ## Exit Codes @@ -40,22 +40,22 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id`, `--high-availability` are telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id` is telemetry-safe, matching Go) | ## Flags -| Flag | Type | Required (non-interactive) | Description | -| --------------------- | ------ | -------------------------- | ----------------------------------------------- | -| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | -| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | -| `--db-password` | string | yes (non-interactive) | Database password for the project | -| `--region` | enum | yes (non-interactive) | AWS region for the project | -| `--size` | enum | no | Desired instance size | -| `--high-availability` | bool | no | Enable high availability for the project | -| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | -| `--plan` | string | no | Plan selection (hidden flag) | +| Flag | Type | Required (non-interactive) | Description | +| --------------------- | ------ | -------------------------- | ---------------------------------------------------------------------------- | +| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | +| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | +| `--db-password` | string | yes (non-interactive) | Database password for the project | +| `--region` | enum | yes (non-interactive) | AWS region for the project | +| `--size` | enum | no | Desired instance size | +| `--high-availability` | bool | no | Enable high availability for the project (**TS-only, no Go CLI equivalent**) | +| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | +| `--plan` | string | no | Plan selection (hidden flag) | ## Output @@ -93,4 +93,8 @@ One `result` event on success. flags and the positional project name argument are required. - The `--size` flag, when provided, sets the `desired_instance_size` field in the request body. - The `--high-availability` flag, when provided, sets the `high_availability` field in the request body. + This is a TS-only flag with no Go CLI equivalent: `apps/cli-go/cmd/projects.go`'s `init()` (~line 133) + never registers a `high-availability` flag, and the create command's `RunE` closure (~line 74) never sets + `HighAvailability` on the request body, even though the underlying API field exists — matching how + `--reveal` is disclosed on `projects api-keys`. - The `--plan` flag is hidden and reserved. diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index 2486931973..29e63c37ad 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -27,25 +27,24 @@ const AWS_REGIONS = [ ] as const; const INSTANCE_SIZES = [ - "nano", - "micro", - "small", - "medium", "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", + "medium", + "micro", "12xlarge", "16xlarge", "24xlarge", "24xlarge_high_memory", "24xlarge_optimized_cpu", "24xlarge_optimized_memory", + "2xlarge", "48xlarge", "48xlarge_high_memory", "48xlarge_optimized_cpu", "48xlarge_optimized_memory", + "4xlarge", + "8xlarge", + "small", + "xlarge", ] as const; const config = { @@ -69,6 +68,10 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), + // TS-only, no Go CLI equivalent: `cmd/projects.go`'s `init()` never registers a + // `high-availability` flag, and the `RunE` closure's `api.V1CreateProjectBody{...}` + // never sets `HighAvailability` even though the API field exists — disclosed in + // SIDE_EFFECTS.md, matching how `--reveal` is disclosed on `projects api-keys`. highAvailability: Flag.boolean("high-availability").pipe( Flag.withDescription("Enable high availability for the project."), Flag.optional, @@ -99,7 +102,13 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( ]), Command.withHandler((flags) => legacyProjectsCreate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id", "high-availability"] }), + // `high-availability` is intentionally not in `safeFlags`: Go marks only + // `org-id` telemetry-safe (`markFlagTelemetrySafe`), and it's a boolean flag + // anyway — boolean values are always logged verbatim by the instrumentation + // regardless of `safeFlags`. See the same pattern on `projects api-keys`'s + // `--reveal`. `config` is passed so `region`/`size` (both `Flag.choice`) + // are auto-detected as telemetry-safe, matching Go's `isEnumFlag`. + withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index f21138df6c..d38c35c021 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -1,8 +1,10 @@ import type { OrganizationResponseV1, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; +import { Command } from "effect/unstable/cli"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { type LegacyApiResponse, type LegacyHttpMethod, @@ -13,7 +15,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import type { LegacyProjectsCreateFlags } from "./create.command.ts"; +import { legacyProjectsCreateCommand, type LegacyProjectsCreateFlags } from "./create.command.ts"; import { legacyProjectsCreate } from "./create.handler.ts"; const CREATED: typeof V1CreateAProjectOutput.Type = { @@ -451,4 +453,54 @@ describe("legacy projects create integration", () => { expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + + // Go parity (`apps/cli-go/cmd/projects.go:34-55`): Go's --size EnumFlag is an + // 18-value list that does not include "nano" (or "pico") and rejects any other + // value at flag-parse time. TS previously listed "nano" as a valid choice, + // silently succeeding where Go errors. + it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const root = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyProjectsCreateCommand]), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })([ + "create", + "alpha", + "--org-id", + "acme", + "--db-password", + "s3cret-pass", + "--region", + "us-east-1", + "--size", + "nano", + ]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); + } + }) as Effect.Effect; + }); }); + +// Distinguishes "the --size flag itself was rejected at parse time" from any +// other failure (e.g. a missing runtime service in this minimal test setup), +// so the regression test above can't pass for the wrong reason. +function rejectsInvalidSizeChoice(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("errors" in error)) return false; + const { errors } = error; + if (!Array.isArray(errors)) return false; + return errors.some( + (candidate: unknown) => + typeof candidate === "object" && + candidate !== null && + "_tag" in candidate && + candidate._tag === "InvalidValue" && + "option" in candidate && + candidate.option === "size", + ); +} diff --git a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md index f4e46eab50..3d94401b92 100644 --- a/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/delete/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md index f4785c0f14..8ddce1209a 100644 --- a/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/list/SIDE_EFFECTS.md @@ -24,7 +24,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md index a7a248607e..9a1ce5d299 100644 --- a/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/list/SIDE_EFFECTS.md @@ -32,7 +32,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 7094cf2336..385c0911fe 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -82,9 +82,3 @@ export class LegacySecretsUnsetCancelledError extends Data.TaggedError( )<{ readonly message: string; }> {} - -export class LegacySecretsConfigParseError extends Data.TaggedError( - "LegacySecretsConfigParseError", -)<{ - readonly message: string; -}> {} diff --git a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md index c99884485e..fa3c8eec26 100644 --- a/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/set/SIDE_EFFECTS.md @@ -2,17 +2,17 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig` | -| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | -| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | -| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadProjectConfig`; a parse failure is logged to the debug logger and tolerated (Go parity), not fatal | +| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | +| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | +| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written @@ -37,7 +37,6 @@ | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | | `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim (Go parity). | — | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes @@ -52,7 +51,6 @@ | `1` | `LegacyInvalidSecretPairError` — positional argument missing `=` | | `1` | `LegacySecretsEnvFileOpenError` — `--env-file` cannot be opened | | `1` | `LegacySecretsEnvFileParseError` — `--env-file` cannot be parsed | -| `1` | `LegacySecretsConfigParseError` — `supabase/config.toml` cannot be parsed | | `1` | `LegacySecretsSetUnexpectedStatusError` — non-2xx response from POST | | `1` | `LegacySecretsSetNetworkError` — transport-level network failure | @@ -85,4 +83,5 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. - `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadProjectConfig` + `resolveProjectSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler — matches Go's `set.go:48-52` which filters by `len(secret.SHA256) > 0` (the SHA256 is empty when `DecryptSecretHookFunc` sees a still-literal `env(VAR)`). +- A malformed `config.toml` does **not** abort the command — matches Go's `set.go:20-24`, which logs the `LoadConfig` error to the debug logger and proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class, matching Go's `viper`+`mapstructure` decode (`pkg/config/config.go:749`), which mutates the target struct field-by-field: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers — Go parity. diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 422f503070..53a46ee15f 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -1,9 +1,17 @@ -import { loadProjectConfig, loadProjectEnvironment, resolveProjectSubtree } from "@supabase/config"; +import { + loadProjectConfig, + loadProjectEnvironment, + ProjectConfigSchema, + resolveProjectSubtree, + type ProjectConfig, + type ProjectConfigParseError, +} from "@supabase/config"; import { parse as parseDotenv } from "dotenv"; -import { Effect, FileSystem, Option, Path, Redacted } from "effect"; +import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -11,7 +19,6 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyInvalidSecretPairError, - LegacySecretsConfigParseError, LegacySecretsEnvFileOpenError, LegacySecretsEnvFileParseError, LegacySecretsNoArgumentsError, @@ -27,12 +34,118 @@ const mapSetError = mapLegacyHttpError({ statusMessage: (_status, body) => `Unexpected error setting project secrets: ${body}`, }); +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +// Excludes arrays, matching `packages/config/src/io.ts`'s `isObject` (the +// identical "is this a table" check used when merging `[remotes.*]`). A TOML +// array for a map-typed field (e.g. `[edge_runtime] secrets = ["actual-secret"]`) +// is not a recoverable table: `Object.entries` on an array yields index keys +// ("0", "1", ...), which would otherwise fabricate spurious secret names. Go's +// mapstructure decoder never does this either — `UnmarshalExact` +// (`apps/cli-go/pkg/config/config.go:749`) never sets `WeaklyTypedInput`, so a +// slice source for a map-typed field hits `UnconvertibleTypeError` in +// `decodeMap` rather than the index-as-key `decodeMapFromSlice` path, and the +// whole field is left empty. +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Best-effort recovery for a schema-decode failure (as opposed to a raw + * TOML/JSON parse failure) on `supabase/config.toml`. Go's `viper`+ + * `mapstructure` decode (`apps/cli-go/pkg/config/config.go:749`) mutates the + * target struct field-by-field: a type error anywhere — an unrelated + * top-level table (`analytics.port`), a sibling field inside the same + * `edge_runtime` table (`edge_runtime.inspector_port`), *or* a single bad + * entry inside the `edge_runtime.secrets` map itself (`BAD = 123`) — does not + * stop the rest of `edge_runtime.secrets` from landing in `utils.Config`. + * `UnmarshalExact` still populates every field (and every map entry) it *can* + * decode before aggregating errors: `mapstructure`'s map decoder + * (`decodeMapFromMap`) iterates each key independently, appends a per-entry + * error and `continue`s rather than aborting, then still calls `val.Set` with + * whatever entries succeeded. Confirmed empirically against this repo's + * actual `pkg/config`: a TOML with both a malformed `edge_runtime.inspector_port` + * and a valid `[edge_runtime.secrets]` block still yields a populated + * `EdgeRuntime.Secrets` (`InspectorPort` is left at its zero value), and a + * `[edge_runtime.secrets]` block with one bad entry alongside a good one + * still yields the good entry. + * `Schema.decodeUnknownSync` has no such tolerance; a single bad field + * anywhere discards the whole decode — re-decoding the *entire* `edge_runtime` + * subtree would still fail in the sibling-field case (`inspector_port` comes + * along for the ride), and re-decoding the whole `secrets` map atomically + * would still fail when just one entry in that map is bad. To keep + * `secrets set` at parity without loosening `packages/config`'s decode + * semantics for every caller: re-slice `edge_runtime.secrets` out of the + * pre-decode document (`cause.document` — only set when the document itself + * parsed fine and the *schema* decode is what failed, see + * `ProjectConfigParseError`), decode each entry independently and keep only + * the ones that succeed (mirroring `decodeMapFromMap`'s per-key tolerance), + * then decode the filtered map against the full schema, where every other + * field (including the rest of `edge_runtime`) defaults cleanly. A true parse + * failure (`cause.document` undefined) has no recoverable structure in either + * implementation — Go's own `viper.MergeConfig` also fails the whole load + * before `mapstructure` ever runs in that case. + */ +function recoverEdgeRuntimeConfig(cause: ProjectConfigParseError): ProjectConfig | null { + if (cause.document === undefined) { + return null; + } + const edgeRuntime = cause.document.edge_runtime; + const secretsField = isRecord(edgeRuntime) ? edgeRuntime.secrets : undefined; + // `redactEdgeRuntimeSecrets` (`packages/config/src/io.ts`) wraps a malformed, + // non-object `secrets` field (e.g. a TOML array) in a single `Redacted` + // rather than leaving it a plain record, so an uncaught error can't leak it + // either. Unwrap before the `isRecord` check below — otherwise the + // `Redacted` wrapper object itself (an object, just not a secrets map) gets + // misread as a one-entry map and fabricates a bogus secret from its + // internal fields. + const secrets = Redacted.isRedacted(secretsField) ? Redacted.value(secretsField) : secretsField; + const decodableSecrets = isRecord(secrets) ? filterDecodableSecrets(secrets) : undefined; + try { + return decodeProjectConfig({ + edge_runtime: decodableSecrets !== undefined ? { secrets: decodableSecrets } : {}, + }); + } catch { + return null; + } +} + +/** + * Mirrors mapstructure's per-entry map decode tolerance + * (`decodeMapFromMap`, invoked via `v.UnmarshalExact` in + * `apps/cli-go/pkg/config/config.go:749`): a decode error on one secret + * value doesn't discard the whole `[edge_runtime.secrets]` map — only that + * entry is dropped, and every other entry is still recovered. + * + * Each value arrives wrapped in `Redacted` (whatever its underlying type) — + * `ProjectConfigParseError.document` wraps every `edge_runtime.secrets` entry + * so an uncaught parse error can't leak a resolved secret, or a malformed + * non-string entry, into a log or trace (see the field doc on `.document`). + * Unwrap before re-decoding: `secret()`'s schema is a plain `Schema.String`, + * not `Redacted`, and a non-string entry (e.g. an array) still fails that + * decode and is dropped below, same as it would in Go. + */ +function filterDecodableSecrets(secrets: Record): Record { + const kept: Record = {}; + for (const [name, value] of Object.entries(secrets)) { + const plainValue = Redacted.isRedacted(value) ? Redacted.value(value) : value; + try { + decodeProjectConfig({ edge_runtime: { secrets: { [name]: plainValue } } }); + kept[name] = plainValue; + } catch { + // Drop this entry only, matching mapstructure's per-key error handling. + } + } + return kept; +} + export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( flags: LegacySecretsSetFlags, ) { const output = yield* Output; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const debugLogger = yield* LegacyDebugLogger; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; @@ -53,28 +166,147 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // literals stay as plain strings, so `Redacted.isRedacted(...)` is the // equivalent guard. const merged = new Map(); - const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe( - Effect.catchTag("ProjectConfigParseError", (cause) => - Effect.fail( - new LegacySecretsConfigParseError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), + // Go swallows a malformed config.toml (or a malformed `.env`/`.env.local` + // sibling — see the `ProjectEnvParseError` catch below) here + // (`internal/secrets/set/set.go:20-24`: `fmt.Fprintln(utils.GetDebugLogger(), err)`) + // and proceeds with an empty `EdgeRuntime.Secrets` — env-file and + // positional-arg secrets still work. `secrets set` has no + // `--linked`/`--local`/`--db-url` flag, so (unlike most commands) the root + // `PreRun` never loads the config first either; this is the only load, and + // it must not be fatal. + // + // Pass `ref` so a matching `[remotes.*]` block is merged over the base + // config before decode, mirroring Go's `flags.LoadConfig` + // (`internal/utils/flags/config_path.go:11-12`: `utils.Config.ProjectId = + // ProjectRef` before `Load()`) merging the override in `loadFromFile` + // (`pkg/config/config.go:604-609`) ahead of the tolerant decode below. + // Without this, a schema-decode error on `--project-ref ` + // would recover the *base* `[edge_runtime.secrets]` instead of the + // explicitly selected remote's override. + // `goViperCompat: true` opts into `applyRemoteOverride`'s duplicate- + // `project_id`/format checks (`packages/config/src/io.ts`) — required for + // the `DuplicateRemoteProjectIdError` catch below to ever fire. + const loadedConfig = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( + Effect.flatMap((loaded) => { + if (loaded === null) { + return Effect.succeed(null); + } + // Go prints this from inside config load, before any command output + // (`pkg/config/config.go:605`) — unconditionally on a matching + // `[remotes.*]` block, ahead of the (possibly failing) decode. Other + // legacy handlers surface it the same way (e.g. `config push`); this + // path must not silently drop it just because it maps straight down + // to `.config` below. + return ( + loaded.appliedRemote !== undefined + ? output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr") + : Effect.void + ).pipe(Effect.as(loaded.config)); + }), + Effect.catchTag("ProjectConfigParseError", (cause) => { + // `smol-toml`'s `TomlError` embeds a source codeblock after a + // blank-line separator — literal file content, which for this file's + // `[edge_runtime.secrets]` section can include real secret values. + // Truncating before the separator handles that case (`cause.document + // === undefined`, a raw parse failure with no decoded document to + // recover from — see the field doc on `ProjectConfigParseError`). + // + // A schema-decode error (`cause.document !== undefined`) has no such + // separator: Effect's `ParseError` puts the rejected value inline on + // one line (e.g. `Expected string, actual ["actual-secret"]`), which + // the truncation above wouldn't catch. Go's pinned mapstructure + // decode-error types (`UnconvertibleTypeError.Error()`, + // `DecodeError.Error()`, `github.com/go-viper/mapstructure/v2 + // v2.5.0`) never include the rejected value, only type names — so a + // fixed, content-free message here matches Go's actual behaviour + // rather than just being defensive. + const shortMessage = + cause.document === undefined + ? String(cause.cause).split("\n\n")[0] + : "schema validation failed"; + // Go prints the override notice unconditionally as soon as a + // `[remotes.*]` block's `project_id` matches, *before* `mapstructure` + // decode ever runs (`pkg/config/config.go:604-609`) — so the notice is + // still owed here even though decode subsequently failed and this + // whole load is non-fatal. `cause.appliedRemote` carries that match + // through the failed decode (see the field doc on + // `ProjectConfigParseError.appliedRemote`); the success path above + // handles the non-error case. Emitted ahead of the debug log below to + // match Go's actual order: the print happens inside `loadFromFile`, + // the debug log only after `flags.LoadConfig` returns the swallowed + // error to `Run` (`internal/secrets/set/set.go:20-24`). + return ( + cause.appliedRemote !== undefined + ? output.raw(`Loading config override: [remotes.${cause.appliedRemote}]\n`, "stderr") + : Effect.void + ).pipe( + Effect.andThen( + debugLogger.debug(`failed to parse supabase/config.toml: ${shortMessage}`), + ), + Effect.as(recoverEdgeRuntimeConfig(cause)), + ); + }), + // `loadProjectConfig` resolves `env(VAR)` references against + // `.env`/`.env.local` (`loadProjectEnvironment` inside + // `loadProjectConfigFile`) *before* schema decode, so a malformed dotenv + // line fails with this distinct tag rather than `ProjectConfigParseError`. + // Go's `Load()` (`pkg/config/config.go:788-791`) calls `loadNestedEnv` + // first too and returns immediately on error, before `loadFromFile` (the + // TOML parse) ever runs — so `EdgeRuntime.Secrets` never gets populated + // in this failure path, unlike the schema-decode-only case above. Recover + // to `null`, not `recoverEdgeRuntimeConfig`: there is no parsed document + // to recover a subtree from. + Effect.catchTag("ProjectEnvParseError", (cause) => + debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), + ), + // Two `[remotes.*]` blocks declare the same `project_id` as `ref` — Go's + // `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including this one, which + // `loadFromFile` raises before `mapstructure` ever runs + // (`pkg/config/config.go:601`). `cause.message` already matches Go's + // string verbatim (see `DuplicateRemoteProjectIdError`'s field doc). + Effect.catchTag("DuplicateRemoteProjectIdError", (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), + ), + // A `[remotes.*]` block's `project_id` fails Go's ref-pattern check — + // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which + // runs inside the same `Config.Load()` call as the duplicate check above + // (`config.go:882`). Go's `flags.LoadConfig` swallows this the same + // non-fatal way (`internal/secrets/set/set.go:22-24`), so a malformed + // remote block must not abort an otherwise-valid `secrets set`. + // `cause.message` already matches Go's string verbatim (see + // `InvalidRemoteProjectIdError`'s field doc). + Effect.catchTag("InvalidRemoteProjectIdError", (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), ), ); - if (loaded !== null) { + if (loadedConfig !== null) { const projectEnv = yield* loadProjectEnvironment({ cwd: runtimeInfo.cwd, baseEnv: process.env, }); if (projectEnv !== null) { const resolved = yield* resolveProjectSubtree( - loaded.config.edge_runtime, + loadedConfig.edge_runtime, projectEnv, "edge_runtime", + { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { - if (Redacted.isRedacted(value)) { + // Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) never + // hashes an empty value, and `ListSecrets` (`internal/secrets/set/set.go:48-52`) + // only includes config entries with a non-empty SHA256 — so an empty + // `[edge_runtime.secrets]` entry is silently skipped rather than sent + // as an empty-string overwrite of a remote secret. `Redacted.isRedacted` + // already excludes the other SHA256-empty case (a still-literal + // `env(VAR)` reference); check for a non-empty value too so both + // zero-hash cases match. This applies to config-sourced secrets only — + // an explicit `--env-file`/positional `NAME=` below is sent as-is, + // matching Go's unconditional `maps.Copy`/assignment for those sources. + if (Redacted.isRedacted(value) && Redacted.value(value).length > 0) { merged.set(name, Redacted.value(value)); } } diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts index 524ee4f3f7..4e6a3e4dfd 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -16,8 +16,23 @@ import { mockLegacyPlatformApi, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { legacySecretsSet } from "./set.handler.ts"; +function mockLegacyDebugLoggerTracked() { + const messages: Array = []; + return { + messages, + layer: Layer.succeed(LegacyDebugLogger, { + debug: (message) => + Effect.sync(() => { + messages.push(message); + }), + http: () => Effect.void, + }), + }; +} + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- @@ -40,6 +55,7 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const debugLogger = mockLegacyDebugLoggerTracked(); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -49,8 +65,9 @@ function setup(opts: SetupOpts = {}) { }), mockRuntimeInfo({ cwd: tempRoot.current }), processEnvLayer(opts.env ?? {}), + debugLogger.layer, ); - return { layer, out, api }; + return { layer, out, api, debugLogger }; } function writeConfig(content: string) { @@ -58,6 +75,11 @@ function writeConfig(content: string) { writeFileSync(join(tempRoot.current, "supabase", "config.toml"), content); } +function writeSupabaseDotEnv(content: string) { + mkdirSync(join(tempRoot.current, "supabase"), { recursive: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), content); +} + function parsePostBody(body: unknown): Array<{ name: string; value: string }> { // `mockLegacyPlatformApi` JSON-decodes the request body when it parses; this // helper just narrows the type for the test assertions. @@ -216,6 +238,34 @@ LITERAL = "plain-value" }).pipe(Effect.provide(layer)); }); + it.live( + "skips an empty [edge_runtime.secrets] value instead of overwriting a remote secret (Go set.go:48-52 parity)", + () => { + // Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) leaves `SHA256` + // empty for an empty value, and `ListSecrets` only includes entries with + // `len(secret.SHA256) > 0` — so a literal `EMPTY = ""` in config.toml is + // never sent, which prevents it from silently overwriting a same-named + // remote secret with an empty string. + writeConfig( + `[edge_runtime.secrets] +EMPTY = "" +NON_EMPTY = "config-value" +`, + ); + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "NON_EMPTY", value: "config-value" }, + ]); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "does not crash when config.toml has env(NUMERIC_PORT) on an unrelated numeric field (CLI-1489 regression guard)", () => { @@ -317,23 +367,474 @@ FOO = "literal-foo" }).pipe(Effect.provide(layer)); }); - it.live("fails with LegacySecretsConfigParseError when config.toml is malformed", () => { - writeConfig("this is not valid = = toml [[[\n"); - const { layer } = setup(); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - legacySecretsSet({ + it.live( + "tolerates a malformed config.toml, logs it to the debug logger, and still sets CLI-arg secrets", + () => { + writeConfig("this is not valid = = toml [[[\n"); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], - }), + }); + expect(api.requests).toHaveLength(1); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers [edge_runtime.secrets] when an unrelated field fails schema decode (CLI-1867 Go parity)", + () => { + // Valid TOML syntax throughout, but `analytics.port` has the wrong type + // for its schema field. Go's viper+mapstructure decode + // (`pkg/config/config.go:749`) mutates the target struct field-by-field, + // so an unrelated type error doesn't stop `EdgeRuntime.Secrets` from + // landing on `utils.Config` — `secrets set` still reads it. Effect + // Schema's `decodeUnknownSync` is atomic and would otherwise discard the + // whole document, silently dropping `FROM_CONFIG` too. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[analytics] +port = "not-a-number" +`, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsConfigParseError"); - } - }).pipe(Effect.provide(layer)); - }); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers [edge_runtime.secrets] when a sibling field in the same edge_runtime table fails schema decode (CLI-1867 Go parity)", + () => { + // Valid TOML syntax throughout, but `edge_runtime.inspector_port` has + // the wrong type for its schema field — a SIBLING of `secrets` inside + // the same `edge_runtime` table, not an unrelated top-level table. Go's + // viper+mapstructure decode (`pkg/config/config.go:749`) mutates the + // target struct field-by-field even within the same table, so + // `EdgeRuntime.Secrets` still lands on `utils.Config` while + // `InspectorPort` is left at its zero value — verified empirically + // against `pkg/config` directly. The recovery must therefore re-decode + // `secrets` on its own rather than the whole `edge_runtime` subtree. + writeConfig( + `[edge_runtime] +inspector_port = "not-a-number" + +[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates a malformed supabase/.env, logs it to the debug logger, and still sets CLI-arg secrets (CLI-1867 Go parity)", + () => { + // `loadProjectConfig` resolves `env(VAR)` references against + // `supabase/.env`/`.env.local` *before* schema decode, so a malformed + // dotenv line fails with `ProjectEnvParseError` rather than + // `ProjectConfigParseError`. Go's `Load()` (`pkg/config/config.go:788-791`) + // calls `loadNestedEnv` first too and swallows any error the same way + // `flags.LoadConfig` does in `internal/secrets/set/set.go:20-24` — so this + // must not abort the command either. `.env` is only read once a + // `supabase/config.toml`/`.json` is found (`findProjectPaths`), so a + // config.toml must exist here too. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + writeSupabaseDotEnv("THIS IS NOT A VALID DOTENV LINE\n"); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(api.requests).toHaveLength(1); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers valid [edge_runtime.secrets] entries when a sibling entry in the same map fails schema decode (CLI-1867 Go parity)", + () => { + // `GOOD` is a valid secret value; `BAD` is not (a non-string TOML value + // for a field whose schema expects a string-like secret). Go's + // mapstructure decodes `map[string]Secret` entry-by-entry + // (`decodeMapFromMap`), appending a per-entry error and continuing + // rather than discarding the whole map, so `GOOD` still lands on + // `utils.Config.EdgeRuntime.Secrets` even with `BAD` present. Effect + // Schema's `decodeUnknownSync` is atomic per record and would otherwise + // discard `GOOD` too when re-decoding the whole `secrets` map at once. + writeConfig( + `[edge_runtime.secrets] +GOOD = "config-value" +BAD = 123 +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + const body = parsePostBody(api.requests[0]?.body); + expect(body).toEqual([{ name: "GOOD", value: "config-value" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "skips an empty recovered [edge_runtime.secrets] entry alongside an unrelated schema error (Go set.go:48-52 parity)", + () => { + // Same empty-value skip as the happy path, but exercised through + // `recoverEdgeRuntimeConfig`/`filterDecodableSecrets`: `EMPTY` decodes + // fine on its own (it's a valid, if empty, string), so it must be + // dropped downstream in the same merge loop the happy path uses, not + // resurrected as a false "recoverable" entry. + writeConfig( + `[edge_runtime.secrets] +EMPTY = "" +GOOD = "config-value" + +[analytics] +port = "not-a-number" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "GOOD", value: "config-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not fabricate a secret named 0 when [edge_runtime.secrets] is an array (CLI-1867 Go parity)", + () => { + // `edge_runtime.secrets` as an array (instead of a table) is not + // recoverable structure: Go's mapstructure decoder never sets + // `WeaklyTypedInput`, so a slice source for a map-typed field hits + // `UnconvertibleTypeError` in `decodeMap` rather than the index-as-key + // `decodeMapFromSlice` path, and the whole field is left empty. Before + // the `isRecord` fix, `Object.entries(["actual-secret"])` would turn + // this into a spurious `{ "0": "actual-secret" }` entry. + writeConfig( + `[analytics] +port = "not-a-number" + +[edge_runtime] +secrets = ["actual-secret"] +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + const body = parsePostBody(api.requests[0]?.body); + expect(body).toEqual([{ name: "FOO", value: "bar" }]); + expect(body.find((entry) => entry.name === "0")).toBeUndefined(); + expect(debugLogger.messages).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "recovers the selected remote's [edge_runtime.secrets] override, not the base, on schema-decode error (CLI-1867 Go parity)", + () => { + // `analytics.port` is an unrelated schema-decode error that triggers the + // recovery path. `remotes.staging.project_id` matches the ref the + // resolver defaults to (`mockLegacyCliConfig`'s `LEGACY_VALID_REF`), so + // Go seeds `Config.ProjectId` before `Load()` + // (`internal/utils/flags/config_path.go:11-12`) and merges the remote + // override in `loadFromFile` (`pkg/config/config.go:604-609`) before the + // tolerant decode this PR models — the recovered secret must reflect the + // remote's override value, not the base document's. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "base-value" + +[analytics] +port = "not-a-number" + +[remotes.staging] +project_id = "${LEGACY_VALID_REF}" + +[remotes.staging.edge_runtime.secrets] +FROM_CONFIG = "remote-value" +`, + ); + const { layer, out, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "remote-value" }, + ]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); + // Go prints the override notice unconditionally as soon as the + // `project_id` match is found, *before* `mapstructure` decode ever + // runs (`pkg/config/config.go:604-609`) — so it's still owed here + // even though the decode that follows fails and recovers. + expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "prints the remote override notice to stderr when [remotes.*] matches the resolved ref (Go parity: pkg/config/config.go:605)", + () => { + // No decode error here — the plain success path. Go's `loadFromFile` + // prints `Loading config override: [remotes.]` to stderr + // unconditionally whenever a `[remotes.*]` block's `project_id` matches + // `Config.ProjectId`, before `mapstructure` ever runs. `mockLegacyCliConfig` + // defaults the resolved ref to `LEGACY_VALID_REF`. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "base-value" + +[remotes.staging] +project_id = "${LEGACY_VALID_REF}" + +[remotes.staging.edge_runtime.secrets] +FROM_CONFIG = "remote-value" +`, + ); + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "remote-value" }, + ]); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not print a remote override notice when no [remotes.*] block matches the resolved ref", + () => { + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "config-value" }, + ]); + expect(out.stderrText).not.toContain("Loading config override"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates two [remotes.*] blocks sharing the target project_id, logs it, and still sets CLI-arg secrets (CLI-1867 Go parity)", + () => { + // Go's `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including the duplicate- + // `project_id` error `loadFromFile` raises before `mapstructure` ever + // runs (`pkg/config/config.go:601`). There is no parsed document to + // recover a subtree from, so config-sourced secrets are dropped + // entirely — only CLI-arg secrets survive. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[remotes.a] +project_id = "dupe-project-id" + +[remotes.b] +project_id = "dupe-project-id" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("duplicate project_id for [remotes."); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "tolerates a [remotes.*] block with a malformed project_id and still sets CLI-arg secrets (Go parity)", + () => { + // Go's `flags.LoadConfig` swallows *any* `Load()` error non-fatally + // (`internal/secrets/set/set.go:22-24`), including the invalid-format + // error `Config.Validate` raises for every `[remotes.*].project_id` + // that doesn't match Go's ref pattern (`pkg/config/config.go:996-1001`), + // which runs inside the same `Config.Load()` call (`config.go:882`) as + // the duplicate check above. There is no parsed document to recover a + // subtree from, so config-sourced secrets are dropped entirely — only + // CLI-arg secrets survive. + writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" + +[remotes.a] +project_id = "not-a-valid-ref" +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).toContain("Invalid config for remotes.a.project_id"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not echo a literal secret value from config.toml into the debug log on a syntax error", + () => { + // `smol-toml`'s `TomlError` embeds a source codeblock (the offending line ±1) + // in its message; the planted secret sits directly above the syntax error so + // it would land inside that codeblock if the handler logged the raw message. + writeConfig( + [ + "[edge_runtime.secrets]", + 'PLANTED_SECRET = "sk_live_TOTALLY_REAL_SECRET_VALUE"', + "BROKEN = = invalid[[[", + ].join("\n"), + ); + const { layer, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).not.toContain("PLANTED_SECRET"); + expect(debugLogger.messages[0]).not.toContain("sk_live_TOTALLY_REAL_SECRET_VALUE"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not echo a literal secret value from config.toml into the debug log on a schema-decode error", + () => { + // Unlike the syntax-error case above, a schema-decode failure has no + // blank-line-separated source codeblock to truncate: Effect's decode + // error puts the rejected value inline on one line (e.g. `Expected + // string, actual ["sk_live_TOTALLY_REAL_SECRET_VALUE"]`). The bad entry + // sits inside `[edge_runtime.secrets]` itself, so this also exercises + // the per-entry recovery path — `PLANTED_SECRET` is dropped, but the + // CLI-arg secret still goes through. + writeConfig( + `[edge_runtime.secrets] +PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] +`, + ); + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([{ name: "FOO", value: "bar" }]); + expect(debugLogger.messages).toHaveLength(1); + expect(debugLogger.messages[0]).not.toContain("PLANTED_SECRET"); + expect(debugLogger.messages[0]).not.toContain("sk_live_TOTALLY_REAL_SECRET_VALUE"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "still fails with LegacySecretsNoArgumentsError when a malformed config leaves zero secret sources", + () => { + writeConfig("this is not valid = = toml [[[\n"); + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsNoArgumentsError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); it.live("fails with LegacySecretsSetNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); diff --git a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md index 504a4d99f1..873a57cf6d 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/secrets/unset/SIDE_EFFECTS.md @@ -33,7 +33,6 @@ | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (Go parity — used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| ~~`SUPABASE_API_URL`~~ | **not honored** — Go parity. Use `SUPABASE_PROFILE` to override the API base URL. | — | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index 67676ca81d..1f33b689e5 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -7,12 +7,13 @@ stack is used; with `--linked` the remote project is used. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | -| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | -| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| Path | Format | When | +| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect | +| `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | +| `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.env*`, `/.env*` | dotenv | when no pre-resolved `yes` is passed in (the standalone command; `db reset --local` passes its own), to resolve `SUPABASE_YES` for the overwrite/prune prompts (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -71,6 +72,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `DOCKER_HOST` | when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | | `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch); for local runs, used as the service-role key instead of `auth.service_role_key` (Go Viper AutomaticEnv parity) | no | | `SUPABASE_AUTH_JWT_SECRET` | local runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation (Go Viper `AutomaticEnv`+`SUPABASE_` prefix parity, `config.go:492-497`) | no | +| `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..f5d04275b7 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,138 +1,31 @@ -import { - loadProjectConfig, - type LoadProjectConfigOptions, - ProjectConfigSchema, -} from "@supabase/config"; -import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import type { PlatformError } from "effect/PlatformError"; +import { Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; -import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../../shared/legacy-storage-credentials.ts"; -import { - legacyParseFileSizeLimit, - legacyResolveBucketProps, -} from "../../../shared/legacy-storage-bucket-config.ts"; -import { - type LegacyStorageGateway, - type LegacyUpsertBucketProps, - legacyMakeStorageGateway, -} from "../../../shared/legacy-storage-gateway.ts"; -import type { LegacyStorageGatewayError } from "../../../shared/legacy-storage-gateway.errors.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { - legacyIsLocalVectorBucketsUnavailable, - legacyIsVectorBucketsFeatureNotEnabled, -} from "./buckets.classify.ts"; -import { LegacySeedConfigLoadError } from "./buckets.errors.ts"; -import { legacyBucketObjectKey } from "./buckets.upload.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; -import { - legacyContentTypeForUpload, - legacyReadSniffBytes, -} from "../../../shared/legacy-storage-content-type.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -const CONFIG_PATH = "supabase/config.toml"; -const UPLOAD_CONCURRENCY = 5; - -/** - * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). - * Used to validate `[storage.buckets]` names before any Storage API call, matching - * Go's config-load-time check (`config.go:899-903`). Vector and analytics names are - * NOT validated here — Go only validates `[storage.buckets]`. - */ -const LEGACY_BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -/** - * Verbatim Go regex literal (`config.go:1382`) — used in the error message so it - * is byte-identical to Go's output. Do NOT derive from `LEGACY_BUCKET_NAME_PATTERN.source`. - */ -const LEGACY_BUCKET_NAME_PATTERN_SOURCE = - "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; - -const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* new LegacySeedConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN_SOURCE})`, - }); - } -}); - -interface CollectedFile { - readonly absPath: string; - readonly displayPath: string; -} - -/** Mutable run summary, emitted as the structured result in json/stream-json mode. */ -interface SeedSummary { - readonly buckets_created: Array; - readonly buckets_updated: Array; - readonly buckets_skipped: Array; - readonly vector_created: Array; - readonly vector_pruned: Array; - vector_skipped: boolean; - readonly objects_uploaded: Array; - readonly analytics_created: Array; - readonly analytics_pruned: Array; -} - -function emptySummary(): SeedSummary { - return { - buckets_created: [], - buckets_updated: [], - buckets_skipped: [], - vector_created: [], - vector_pruned: [], - vector_skipped: false, - objects_uploaded: [], - analytics_created: [], - analytics_pruned: [], - }; -} - -/** - * Embedded-default project config, decoded from an empty object — the same - * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally - * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, initialized - * to embedded defaults, and `config.Load` no-ops on a missing file. So "no - * config file" behaves like the embedded-default config. - */ -const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); - /** * `supabase seed buckets` — seeds Storage buckets from * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. * * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is * passed, the remote Storage gateway is used with the project's service-role key; - * otherwise the local stack is used. + * otherwise the local stack is used. The seeding work lives in the hoisted + * `legacySeedBucketsRun` (shared with `db reset --local`); this handler owns the + * target-flag resolution and the post-run cache + telemetry side effects. */ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // Target is selected from the changed-flag set (Go's flag.Changed), not the // parsed value, so the flags arg itself is unused here. _flags: LegacyBucketsFlags, ) { - const output = yield* Output; - const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const cliArgs = yield* CliArgs; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; // Set once --linked resolves a ref; drives the post-run linked-project cache // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` @@ -141,11 +34,11 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - // 1. Resolve the project ref for --linked BEFORE loading config, so that - // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Go selects the target - // from `flag.Changed`, not the flag value: `--linked` is the linked path - // whenever it's *set* (even `--linked=false`). + // Resolve the project ref for --linked BEFORE loading config, so that the + // matching `[remotes.]` override (whose `project_id == ref`) is merged + // over the base config by `loadProjectConfig`. Go selects the target from + // `flag.Changed`, not the flag value: `--linked` is the linked path whenever + // it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = setFlags.includes("linked") @@ -153,123 +46,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( : ""; linkedRef = projectRef; - // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are - // merged for --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; - const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( - Effect.catchTag( - "ProjectConfigParseError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), - ); - // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls - // into the no-op short-circuit; `--linked` + no-config falls through to the - // remote path so auth/project/API failures surface. - const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; - const document = loaded === null ? undefined : loaded.document; - - // Go prints this from inside config load (`config.go:513`) whenever a - // `[remotes.*]` block matched the linked ref. stderr in all output modes. - if (loaded !== null && loaded.appliedRemote !== undefined) { - yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); - } - const bucketsConfig = config.storage.buckets ?? {}; - const bucketNames = Object.keys(bucketsConfig); - const vectorEnabled = config.storage.vector.enabled; - const vectorBucketNames = Object.keys(config.storage.vector.buckets); - const hasVectorBuckets = vectorBucketNames.length > 0; - - // 3. Config-load-time validations run BEFORE the no-op short-circuit: Go - // decodes the whole config (storage.FileSizeLimit, bucket sizes) and runs - // ValidateBucketName during config.Load — before `buckets.Run` can take its - // no-op path — so an invalid value fails even when there's nothing to seed. - // - // 3a. Bucket names (Go ValidateBucketName, config.go:899-903). - for (const name of bucketNames) { - yield* legacyValidateBucketName(name); - } - - // 3b. Storage-level file_size_limit, parsed unconditionally. - const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( - config.storage.file_size_limit, - ); - - // 3c. Per-bucket props (sizes parsed before any Storage call). - const bucketPropsByName = new Map(); - for (const [name, bucket] of Object.entries(bucketsConfig)) { - bucketPropsByName.set( - name, - yield* computeBucketProps(document, name, bucket, storageFileSizeLimitBytes), - ); - } - - // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). - if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - if (output.format !== "text") { - yield* output.success("", { ...emptySummary() }); - } - return; - } - - // 4. Build the Storage service-gateway client (local or remote). - const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); - - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup - // inside `legacyResolveStorageCredentials` runs BEFORE this scope, so it - // still honors `--dns-resolver https`, matching Go's `tenant.GetApiKeys`. - const gatewayOps = Effect.gen(function* () { - const gateway = yield* legacyMakeStorageGateway({ - baseUrl: credentials.baseUrl, - apiKey: credentials.apiKey, - userAgent: cliConfig.userAgent, - }); - - const summary = emptySummary(); - - // 5. Upsert configured buckets. - yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); - - // 6. Upsert analytics buckets (remote --linked only). - if (config.storage.analytics.enabled && projectRef !== "") { - yield* output.raw("Updating analytics buckets...\n", "stderr"); - yield* upsertAnalyticsBuckets( - output, - yes, - gateway, - Object.keys(config.storage.analytics.buckets), - summary, - ); - } - - // 7. Upsert vector buckets (local), with graceful skip on unavailability. - if (vectorEnabled && hasVectorBuckets) { - yield* output.raw("Updating vector buckets...\n", "stderr"); - yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( - Effect.catch((error) => handleVectorError(output, error, summary)), - ); - } - - // 8. Upload objects for each bucket with a configured objects_path. - yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); - - // 9. Machine-readable summary (Go has none; text mode emits nothing extra). - if (output.format !== "text") { - yield* output.success("", { ...summary }); - } - }); - - yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. @@ -279,315 +56,3 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( Effect.ensuring(telemetryState.flush), ); }); - -type BucketsConfig = Readonly< - Record< - string, - { - readonly public: boolean; - readonly file_size_limit: string; - readonly allowed_mime_types: ReadonlyArray; - readonly objects_path: string; - } - > ->; - -// Parse a `file_size_limit` string to bytes, mapping a parse failure to a -// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, -// before NewStorageAPI). -const parseFileSizeLimitOrFail = (value: string) => - Effect.try({ - try: () => legacyParseFileSizeLimit(value), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -const computeBucketProps = ( - document: Record | undefined, - name: string, - bucket: BucketsConfig[string], - storageFileSizeLimitBytes: number, -) => - Effect.try({ - try: () => legacyResolveBucketProps({ document, name, bucket, storageFileSizeLimitBytes }), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -// Port of `pkg/storage/batch.go:UpsertBuckets`. `propsByName` is precomputed and -// size-validated before this runs (Go parses sizes at config-load, before any -// Storage call). -const upsertBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - propsByName: ReadonlyMap, - summary: SeedSummary, -) { - const existing = yield* gateway.listBuckets(); - const byName = new Map(existing.map((b) => [b.name, b.id])); - - for (const [name, props] of propsByName) { - const bucketId = byName.get(name); - if (bucketId !== undefined) { - const overwrite = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, - true, - ); - if (!overwrite) { - summary.buckets_skipped.push(bucketId); - continue; - } - yield* output.raw(`Updating Storage bucket: ${bucketId}\n`, "stderr"); - yield* gateway.updateBucket(bucketId, props); - summary.buckets_updated.push(bucketId); - } else { - yield* output.raw(`Creating Storage bucket: ${name}\n`, "stderr"); - yield* gateway.createBucket(name, props); - summary.buckets_created.push(name); - } - } -}); - -// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. -const upsertVectorBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listVectorBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating vector bucket: ${name}\n`, "stderr"); - yield* gateway.createVectorBucket(name); - summary.vector_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning vector bucket: ${name}\n`, "stderr"); - yield* gateway.deleteVectorBucket(name); - summary.vector_pruned.push(name); - } -}); - -// Port of `pkg/storage/analytics.go:UpsertAnalyticsBuckets`. -const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listAnalyticsBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating analytics bucket: ${name}\n`, "stderr"); - yield* gateway.createAnalyticsBucket(name); - summary.analytics_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning analytics bucket: ${name}\n`, "stderr"); - yield* gateway.deleteAnalyticsBucket(name); - summary.analytics_pruned.push(name); - } -}); - -/** - * Vector graceful-skip (`buckets.go:57-66`): on `FeatureNotEnabled` / - * local-unavailable errors, print the matching WARNING and continue (object - * upload still runs). Any other error propagates. - */ -const handleVectorError = Effect.fnUntraced(function* ( - output: typeof Output.Service, - error: LegacyStorageGatewayError, - summary: SeedSummary, -) { - if (legacyIsVectorBucketsFeatureNotEnabled(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - if (legacyIsLocalVectorBucketsUnavailable(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in the local storage service. If this project is linked, run \`supabase link\` to update service versions, then restart the local stack. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - return yield* Effect.fail(error); -}); - -// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). -const uploadObjects = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - gateway: LegacyStorageGateway, - workdir: string, - bucketsConfig: BucketsConfig, - summary: SeedSummary, -) { - for (const [name, bucket] of Object.entries(bucketsConfig)) { - const objectsPath = bucket.objects_path; - if (objectsPath.length === 0) { - continue; - } - // Go resolves a relative bucket objects_path against SupabaseDirPath at - // config-resolve time (`pkg/config/config.go:757-759`); absolute paths are - // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` - // stderr and the destination key so both stay byte-identical to Go. - const displayRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join("supabase", objectsPath); - const absRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join(workdir, "supabase", objectsPath); - const files = yield* collectFiles(fs, path, output, absRoot, displayRoot); - yield* Effect.forEach( - files, - (file) => - Effect.gen(function* () { - const dstPath = legacyBucketObjectKey(name, displayRoot, file.displayPath); - yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); - // Content-type is byte-driven: Go sniffs the first 512 bytes with - // http.DetectContentType, refining only a generic text/plain by - // extension (`pkg/storage/objects.go:77-108`). - const sniff = yield* legacyReadSniffBytes(fs, file.absPath); - // Go's seed upload always sets Cache-Control max-age=3600 and x-upsert - // (Overwrite) true (`pkg/storage/batch.go`). - yield* gateway.uploadObject(dstPath, file.absPath, { - contentType: legacyContentTypeForUpload(sniff, file.absPath), - cacheControl: "max-age=3600", - overwrite: true, - }); - summary.objects_uploaded.push(dstPath); - }), - { concurrency: UPLOAD_CONCURRENCY }, - ); - } -}); - -/** - * Collect uploadable files under `absRoot`, lexically ordered, mirroring Go's - * `fs.WalkDir` + `isUploadableEntry` (`pkg/storage/batch.go:65-131`). - * - * Parity details: - * - The **root** is resolved with a following stat (Go's `fs.Stat`), so a - * symlinked `objects_path` is followed; a missing/dangling root fails. - * - **Nested** entries use no-follow detection: real directories are descended; - * symlinks are NOT descended — Go's `isUploadableEntry` OPENS the symlink - * target then stats the handle, uploading only a regular file and skipping - * dangling symlinks / symlinks-to-directories / unreadable targets. - */ -const collectFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absRoot: string, - displayRoot: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const info = yield* fs.stat(absRoot); - if (info.type === "Directory") { - return yield* collectDir(fs, path, output, absRoot, displayRoot); - } - if (info.type === "File") { - return [{ absPath: absRoot, displayPath: displayRoot }]; - } - yield* output.raw(`Skipping non-regular file: ${displayRoot}\n`, "stderr"); - return []; - }); - -const collectDir = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absDir: string, - displayDir: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const names = [...(yield* fs.readDirectory(absDir))].sort(); - const collected: Array = []; - for (const name of names) { - const absChild = path.join(absDir, name); - const displayChild = path.join(displayDir, name); - // `readLink` succeeds only on a symlink — our no-follow detector (Effect's - // `stat` follows symlinks and has no `lstat`). - const isSymlink = yield* fs.readLink(absChild).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (isSymlink) { - // Go `isUploadableEntry` (batch.go:73-84) OPENS the target then stats the - // handle; it uploads only a regular file. `stat` alone would queue an - // unreadable target and abort later at upload, so mirror Go: open + stat. - const targetType = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* fs.open(absChild, { flag: "r" }); - const targetInfo = yield* handle.stat; - return targetInfo.type; - }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); - if (targetType === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - continue; - } - const childInfo = yield* fs.stat(absChild); - if (childInfo.type === "Directory") { - collected.push(...(yield* collectDir(fs, path, output, absChild, displayChild))); - } else if (childInfo.type === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - } - return collected; - }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index 986fcc4af6..1d3ef890c9 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -25,6 +25,7 @@ import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../../legacy/config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../../legacy/config/legacy-project-ref.errors.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; import { LegacyPlatformApi } from "../../../../legacy/auth/legacy-platform-api.service.ts"; @@ -323,6 +324,36 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "honors a piped decline for the overwrite prompt when non-interactive (db reset path)", + () => { + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + ], + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + // db reset seeds buckets with interactive=false (Go's `buckets.Run(ctx, "", + // false, fsys)` forces console.IsTTY=false). Go does NOT silently take the + // default: the prompt still prints its label, scans one line, and honors a + // parsed answer — so the piped "n" must skip the overwrite (default is yes). + const exit = yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties?", + ); + expect(out.stderrText).not.toContain("Updating Storage bucket"); + expect(requests.some((r) => r.method === "PUT")).toBe(false); + }); + }, + ); + it.live("creates configured vector buckets and leaves stale ones (prune default no)", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n[storage.vector.buckets.existing-vec]\n", @@ -397,6 +428,40 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "prunes a stale vector bucket when the caller passes yes (db reset project-env path)", + () => { + // `db reset` resolves `yes` with the nested project `.env` and passes it into the runner + // with `interactive: false`; the pre-resolved `yes` must drive pruning even though no + // prompt is answered and the runner's own shell-only resolveYes would default to false. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { + method: "POST", + match: VECTOR_LIST, + body: { + vectorBuckets: [{ vectorBucketName: "keep-vec" }, { vectorBucketName: "stale-vec" }], + }, + }, + { method: "POST", match: VECTOR_DELETE, body: {} }, + ], + // No `confirm` and no `--yes` flag — pruning is driven solely by the passed `yes`. + }); + return Effect.gen(function* () { + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + yes: true, + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Pruning vector bucket: stale-vec"); + expect(requests.some((r) => r.url.includes(VECTOR_DELETE))).toBe(true); + }); + }, + ); + it.live("warns and continues when vector buckets are unavailable in the region", () => { const { layer, out } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -1346,6 +1411,37 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "auto-confirms the overwrite from SUPABASE_YES in the project .env (Go loadNestedEnv, CLI-1878)", + () => { + // SUPABASE_YES lives only in supabase/.env, not the shell or the --yes flag. `seed + // buckets` defaults to `--local` (Go: `cmd/seed.go:31`), and root's + // `ParseDatabaseConfig` loads the project `.env` files before `buckets.Run`'s + // overwrite prompt (`root.go:118`), so the standalone command's own fallback + // resolution (not the `db reset`-passed `opts.yes`) must read it too. + const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.assets]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "assets", id: "assets" }] }, + { method: "PUT", match: "/storage/v1/bucket/assets", body: {} }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "already exists. Do you want to overwrite its properties? [Y/n] y", + ); + expect(out.stderrText).toContain("Updating Storage bucket: assets"); + expect(requests.some((r) => r.method === "PUT")).toBe(true); + }); + }, + ); + it.live("--yes prunes a stale vector bucket and echoes Go's prompt line", () => { const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.vec1]\n", diff --git a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md index 07092ca913..092f927b61 100644 --- a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md @@ -16,12 +16,22 @@ ## API Routes -The resolved project ref must match `^[a-z]{20}$` (Go's `utils.ProjectRefPattern`) -before any remote lookup runs; a malformed ref skips the linked-version checks -and only the local matrix is printed. Tenant calls send `apikey: ` -and additionally `Authorization: Bearer ` unless the key is a -new-style `sb_…` key (which authenticates via the `apikey` header alone), -matching `apps/cli-go/pkg/fetcher/gateway.go`. +**Divergence from Go on a malformed ref:** Go validates the resolved ref against +`utils.ProjectRefPattern` (`^[a-z]{20}$`) but only warns on failure +(`cmd/services.go`'s `Run` prints the validation error to stderr) and still +calls `listRemoteImages` with the malformed ref anyway (`services.go:61-62`). +TS prints the same warning ("Invalid project ref format. Must be like +`abcdefghijklmnopqrst`.") but deliberately skips the remote lookup instead of +reproducing Go's behavior — the ref is embedded unescaped into the tenant +gateway hostname below, so proceeding with a malformed value would let it +redirect the service-role key to an attacker-controlled host. Only the local +matrix is printed in this case. This is intentional TS-only hardening, not a +parity bug. + +Tenant calls send `apikey: ` and additionally +`Authorization: Bearer ` unless the key is a new-style `sb_…` key +(which authenticates via the `apikey` header alone), matching +`apps/cli-go/pkg/fetcher/gateway.go`. | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---------------------------------------------- | ------------------------------ | ------------ | ------------------------------------------------------------------ | @@ -36,7 +46,7 @@ matching `apps/cli-go/pkg/fetcher/gateway.go`. | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes @@ -75,5 +85,6 @@ TS-only NDJSON success event with the same `{ services: [...] }` payload. - Local versions come from the command's baked-in service matrix; the command does not inspect Docker state or local config files. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. +- A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed, matching Go's own `viper.GetString("PROJECT_ID")` (`internal/utils/flags/project_ref.go:62`). - Version mismatches are reported to stderr as a warning. - `telemetry.json` is written on every invocation, including `--output env` failures, to match the legacy Go command lifecycle. diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index f6dd731220..279575fbe6 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -1,6 +1,10 @@ import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + PROJECT_REF_PATTERN, +} from "../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts"; @@ -62,6 +66,21 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le yield* Effect.gen(function* () { const accessTokenExit = yield* credentials.getAccessToken.pipe(Effect.exit); const accessToken = Exit.isSuccess(accessTokenExit) ? accessTokenExit.value : Option.none(); + + const validLinkedRef = Option.filter(linkedProjectRef, (ref) => PROJECT_REF_PATTERN.test(ref)); + if (Option.isSome(linkedProjectRef) && Option.isNone(validLinkedRef)) { + // Go's `flags.LoadProjectRef` (project_ref.go:54-76) validates the ref but + // `cmd/services.go`'s Run only warns on the error and keeps going, so Go + // still calls `listRemoteImages` with the malformed ref (services.go:61-62). + // TS matches the warning but deliberately skips the remote call instead of + // reproducing it: the ref is embedded unescaped into the tenant gateway + // hostname in `fetchLinkedServiceVersions`, so proceeding would let a + // malformed ref redirect the service-role key to an attacker-controlled host. + // Emitted before the config-load warning below to match the order Go's + // `Run` prints them in (services.go:18-24). + yield* output.raw(`${INVALID_PROJECT_REF_MESSAGE}\n`, "stderr"); + } + const tomlValues = yield* legacyReadDbToml( fs, path, @@ -109,11 +128,11 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le }; let rows = listLocalServiceVersions(localImageOptions); - if (Option.isSome(linkedProjectRef) && Option.isSome(accessToken)) { + if (Option.isSome(validLinkedRef) && Option.isSome(accessToken)) { const remote = yield* fetchLinkedServiceVersions({ apiUrl: cliConfig.apiUrl, projectHost: cliConfig.projectHost, - projectRef: linkedProjectRef.value, + projectRef: validLinkedRef.value, accessToken: accessToken.value, userAgent: cliConfig.userAgent, }); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 7e03294354..86c2ceb807 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command } from "effect/unstable/cli"; import { Stdio } from "effect"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { INVALID_PROJECT_REF_MESSAGE } from "../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { @@ -45,6 +46,8 @@ function setup( format?: "text" | "json" | "stream-json"; goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; workdir?: string; + accessToken?: string; + apiUrl?: string; } = {}, ) { const out = mockOutput({ @@ -68,16 +71,20 @@ function setup( LegacyCliConfig, LegacyCliConfig.of({ profile: "supabase", - apiUrl: "https://api.supabase.com", + apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: Option.none(), workdir: opts.workdir ?? process.cwd(), userAgent: "SupabaseCLI/test", }), ), - Layer.succeed(LegacyCredentials, LegacyCredentials.of(legacyCredentialsMock)), + Layer.succeed( + LegacyCredentials, + LegacyCredentials.of(legacyCredentialsMock(opts.accessToken)), + ), Layer.succeed( LegacyLinkedProjectCache, LegacyLinkedProjectCache.of({ @@ -91,13 +98,19 @@ function setup( }; } -const legacyCredentialsMock = { - getAccessToken: Effect.succeed(Option.none()), - saveAccessToken: () => Effect.die("unexpected saveAccessToken"), - deleteAccessToken: Effect.die("unexpected deleteAccessToken"), - deleteAllProjectCredentials: Effect.void, - deleteProjectCredential: () => Effect.succeed(false), -}; +function legacyCredentialsMock(accessToken?: string) { + return { + getAccessToken: Effect.succeed( + accessToken === undefined + ? Option.none() + : Option.some(Redacted.make(accessToken, { label: "SUPABASE_ACCESS_TOKEN" })), + ), + saveAccessToken: () => Effect.die("unexpected saveAccessToken"), + deleteAccessToken: Effect.die("unexpected deleteAccessToken"), + deleteAllProjectCredentials: Effect.void, + deleteProjectCredential: () => Effect.succeed(false), + }; +} const legacyTestRoot = Command.make("supabase").pipe( Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), @@ -309,6 +322,112 @@ major_version = 15 }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); }); + it.live("warns and skips the remote lookup for a malformed linked project ref", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); + expect(out.stdoutText).toContain("supabase/postgres"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + // A token present doesn't bypass the format guard (Go's warning is + // unconditional on login too) — same code path as the previous test, so this + // isn't new branch coverage, just pinning that login state can't skip it. + it.live("still warns on a malformed ref even when logged in", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const { layer, out } = setup({ workdir, accessToken: "sbp_test-token" }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); + expect(out.stdoutText).toContain("supabase/postgres"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + + it.live("fetches and merges remote versions for a valid ref when logged in", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/v1/projects/abcdefghijklmnopqrst") { + return Response.json({ + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === "/v1/projects/abcdefghijklmnopqrst/api-keys") { + // Deliberately no service-role key: this test only needs to prove the + // handler wires the fetch+merge branch through, not re-test + // `fetchLinkedServiceVersions`'s own tenant-probe logic (already + // covered in services.shared.unit.test.ts). Omitting the + // service-role key keeps this test free of a second, tenant-gateway + // mock without weakening the assertion below. + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + return new Response("not found", { status: 404 }); + }, + }); + + const { layer, out } = setup({ + workdir, + accessToken: "sbp_test-token", + apiUrl: server.url.origin, + goOutput: Option.some("json"), + }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); + const rows = JSON.parse(out.stdoutText) as Array<{ + name: string; + local: string; + remote: string; + }>; + expect(rows).toContainEqual( + expect.objectContaining({ name: "supabase/postgres", remote: "17.6.1.200" }), + ); + }).pipe( + Effect.ensuring( + Effect.promise(() => server.stop(true)).pipe( + Effect.andThen(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), + ), + ), + ); + }); + it.live("reports pinned legacy temp service versions", () => { const workdir = makeProjectWithDbMajorVersion(15); writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); diff --git a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md index 19f3323f9e..ec7b0f9f11 100644 --- a/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/download/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -30,8 +30,7 @@ Only `content.sql` is rendered in text mode. The full payload is exposed via `-- | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md index 867b2331ac..85e3717180 100644 --- a/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/snippets/list/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `LegacyCliConfig.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `PROJECT_ID` env are unset | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -28,8 +28,7 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | | `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md index ccf0e03d43..d56ed25db2 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 6f067ad6b5..2a5657772a 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | | `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.ts b/apps/cli/src/legacy/commands/sso/add/add.command.ts index 4030c121a3..e48071520e 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.command.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.command.ts @@ -84,7 +84,16 @@ export const legacySsoAddCommand = Command.make("add", config).pipe( ]), Command.withHandler((flags) => legacySsoAdd(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ + flags, + safeFlags: ["project-ref"], + config, + // `--type` registers `-t` (Flag.withAlias above); without this, `-t saml` + // never resolves to the canonical `type` name in extractChangedFlagNames, + // so it wouldn't appear in telemetry at all (Go's pflag.Visit reports the + // canonical name regardless of shorthand — cmd/root_analytics.go:53-76). + aliases: { t: "type" }, + }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts index 795b71200a..b2b1636c51 100644 --- a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts @@ -69,4 +69,53 @@ describe("supabase sso (legacy)", () => { expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); }, ); + + // CLI-1901: `add`'s `--type` has no `Flag.optional` (see `add.command.ts`) + // — Go marks it required via `MarkFlagRequired("type")` (`cmd/sso.go:65`) + // — so a missing/invalid `--type` used to dump the full help doc to + // stdout AND print the error twice on stderr. No auth/network call ever + // happens for either case: flag parsing fails before the handler runs. + // + // A missing required flag and an invalid choice value get different + // treatment, matching the real `apps/cli-go/supabase-go` binary (verified + // directly against it): Go's `PersistentPreRunE` sets `SilenceUsage = true` + // (`cmd/root.go:97`) BEFORE `ValidateRequiredFlags` runs, so a missing + // `--type` is a single clean stderr line with no usage block — but + // `Flag.choice` validation happens during `ParseFlags`, BEFORE that point, + // so Go still shows a usage block for an invalid `--type` value, always on + // stderr, never stdout. + test( + "add without --type: stdout stays clean, stderr is a single Go-parity line (no usage block)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(`required flag(s) "type" not set`); + expect(stderr).not.toContain("USAGE"); + expect(stderr.trim().split("\n")).toHaveLength(2); + }, + ); + + test( + "add with an invalid --type value: stdout stays clean, the usage content and the single error line land on stderr with no duplicate", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("USAGE"); + const occurrences = stderr.split(`Invalid value for flag --type: "bogus"`).length - 1; + expect(occurrences).toBe(1); + expect( + stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.ts b/apps/cli/src/legacy/commands/sso/update/update.command.ts index cf6732ddca..2f61eecc19 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.ts @@ -106,7 +106,7 @@ export const legacySsoUpdateCommand = Command.make("update", config).pipe( ]), Command.withHandler((flags) => legacySsoUpdate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"], config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts index 0afeb0e4d2..6f88558830 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -47,6 +47,23 @@ describe("legacy sso update domain flags (pflag StringSlice parity)", () => { expect(removeDomains).toEqual(["example.com", "example.org"]); }); + test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { + // Backs the "changed vs truthy" mutex-check fix (CLI-1902): the handler's + // `hasExplicitLongFlag` reads raw argv rather than this parsed value + // precisely because `--domains=` collapses to `[]` here, indistinguishable + // from the flag never being passed at all if you only looked at `.length`. + const [, domains] = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: [""] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual([]); + }); + test("rejects malformed CSV (bare quote)", async () => { const exit = await Effect.runPromise( legacySsoUpdateDomainsFlag diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..aba1d6833f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -1,5 +1,5 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Result } from "effect"; +import { Effect, Option, Result, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -7,6 +7,10 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitValueFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { encodeGoJson, @@ -48,6 +52,40 @@ const mapGetStatusOrNetwork = mapLegacyHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; + +/** + * Registration order mirrors Go's `cmd/sso.go:178-180` — three independent + * `MarkFlagsMutuallyExclusive` groups (`metadata-file`/`metadata-url` plus two + * 2-element groups sharing `--domains`, not one 3-way group). Cobra checks + * groups in `sort.Strings`-order of the joined group key (`flag_groups.go:189`), + * which happens to match registration order here: "domains add-domains" < + * "domains remove-domains" < "metadata-file metadata-url" alphabetically. + */ +const SSO_UPDATE_MUTEX_GROUPS = [ + ["domains", "add-domains"], + ["domains", "remove-domains"], + ["metadata-file", "metadata-url"], +] as const; + +/** + * Every value-taking (non-boolean) flag `sso update` declares + * (`update.command.ts`) — tells `hasExplicitValueFlag` which bare tokens + * consume the next argv token as their value. `--skip-url-validation` is this + * command's only boolean flag and is deliberately excluded; booleans never + * consume a following token. + */ +const SSO_UPDATE_VALUE_FLAG_NAMES = new Set([ + "project-ref", + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "attribute-mapping-file", + "name-id-format", +]); + const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); @@ -104,34 +142,50 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { + // cobra runs `ValidateFlagGroups` (`command.go:1010`) before `RunE` + // (`command.go:1014`), and Go's provider-ID format check lives inside + // `RunE` (`cmd/sso.go:90-91`) — so a mutex violation must win over an + // invalid provider ID when both apply. Keep this block ahead of + // `validateUuid` below to match that precedence. + // + // "Set" follows cobra's `pflag.Changed` — whether the flag was passed at + // all — not the resulting value. `--domains`/`--add-domains`/ + // `--remove-domains` all default to `[]`, so `--domains=` (parses to an + // empty array) must still count as "set"; gating on `.length > 0` would + // miss it, the same "changed vs truthy" gap CLI-1860 fixed for + // `functions download`'s `--use-docker`. + // + // `hasExplicitValueFlag` (not the simpler `hasExplicitLongFlag`) is + // required here because every flag in these groups takes a value: a bare + // `--metadata-file --metadata-url` is pflag consuming `--metadata-url` as + // `metadata-file`'s (oddly named) value, not two flags being set — see + // that function's doc comment. + for (const group of SSO_UPDATE_MUTEX_GROUPS) { + const changed = group.filter((flagName) => + hasExplicitValueFlag( + rawArgs, + SSO_UPDATE_COMMAND_PATH, + SSO_UPDATE_VALUE_FLAG_NAMES, + flagName, + ), + ); + if (changed.length > 1) { + return yield* Effect.fail( + new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(group, changed), + }), + ); + } + } + const providerId = yield* validateUuid(flags.providerId).pipe( Result.match({ onFailure: Effect.fail, onSuccess: Effect.succeed }), ); - if (flags.domains.length > 0 && flags.addDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --add-domains may be set", - }), - ); - } - if (flags.domains.length > 0 && flags.removeDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --remove-domains may be set", - }), - ); - } - if (Option.isSome(flags.metadataFile) && Option.isSome(flags.metadataUrl)) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --metadata-file or --metadata-url may be set", - }), - ); - } - const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index c1060092cb..c76206a29b 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -45,6 +45,13 @@ interface SetupOpts { putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; + /** + * Raw argv the handler sees via `Stdio.Stdio` — drives the + * `hasExplicitLongFlag`-based mutex checks. Defaults to a bare invocation + * with none of the mutually-exclusive domain flags present; tests that + * exercise those checks must pass the matching flags explicitly here. + */ + cliArgs?: ReadonlyArray; } function jsonResponse( @@ -122,15 +129,20 @@ function setup(opts: SetupOpts = {}) { }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); - const layer = buildLegacyTestRuntime({ - out, - api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, - cliConfig, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - analytics, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + analytics, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + Stdio.layerTest({ + args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), + }), + ); return { layer, out, api, analytics, telemetry, cache }; } @@ -200,40 +212,237 @@ describe("legacy sso update integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --add-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --add-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], addDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Byte-matches cobra's `validateExclusiveFlagGroups` template + // (`flag_groups.go:204`): group in registration order, changed flags + // sorted alphabetically — "add-domains" < "domains". + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --remove-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --remove-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--remove-domains", + "b.com", + ], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], removeDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).toContain( + "if any flags in the group [domains remove-domains] are set none of the others can be; [domains remove-domains] were all set", + ); + } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --metadata-file + --metadata-url fails", () => { - const { layer } = setup(); + it.live( + "mutex check: an explicit but empty --domains= still conflicts with --add-domains (changed, not truthy)", + () => { + // `--domains=` parses to an empty array, but cobra's `pflag.Changed` + // tracks that the flag was passed at all, not the resulting value — the + // same "changed vs truthy" gap CLI-1860 fixed for `functions download`'s + // `--use-docker`. Gating on `.length > 0` would miss this combination. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains=", "--add-domains", "b.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, domains: [], addDomains: ["b.com"] }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: --add-domains and --remove-domains together are not mutually exclusive", + () => { + // Go only registers ("domains","add-domains") and ("domains","remove-domains") + // as separate 2-element groups (`cmd/sso.go:179-180`) — add-domains and + // remove-domains together, without --domains, is not a violation. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, addDomains: ["b.com"], removeDomains: ["c.com"] }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: all three domain flags set reports the --add-domains group first", () => { + // Pins the `SSO_UPDATE_MUTEX_GROUPS` array order: cobra's sorted-key + // iteration ("domains add-domains" < "domains remove-domains") means the + // add-domains group is checked — and its error returned — first when all + // three domain flags collide at once. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + domains: ["a.com"], + addDomains: ["b.com"], + removeDomains: ["c.com"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); + expect(dump).not.toContain("remove-domains"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("mutex check: a flag-group violation wins over an invalid provider ID", () => { + // Cobra runs `ValidateFlagGroups` before `RunE` (`command.go:1010,1014`); + // Go's provider-ID format check lives inside `RunE` (`cmd/sso.go:90-91`). + // So an invalid UUID combined with a mutex violation must surface the + // mutex error, not `LegacySsoInvalidUuidError`. + const { layer } = setup({ + cliArgs: ["sso", "update", "not-a-uuid", "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, - metadataFile: Option.some("/tmp/x.xml"), - metadataUrl: Option.some("https://idp.example.com/m"), + providerId: "not-a-uuid", + domains: ["a.com"], + addDomains: ["b.com"], }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).not.toContain("LegacySsoInvalidUuidError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mutex check: --metadata-file + --metadata-url fails with cobra's exact error text", + () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--metadata-file", + "/tmp/x.xml", + "--metadata-url", + "https://idp.example.com/m", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + metadataFile: Option.some("/tmp/x.xml"), + metadataUrl: Option.some("https://idp.example.com/m"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting + // a hand-written message alongside the domains groups' custom text + // before this fix; now all three of `sso update`'s mutex groups on + // this command share the same byte-exact cobra template. + expect(dump).toContain( + "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: a bare --metadata-file followed by --metadata-url is not a violation", + () => { + // pflag's `--flag arg` branch consumes the very next argv token as the + // value unconditionally (`flag.go:1013-1031`), so real cobra parses this + // as `metadata-file` receiving the literal value `"--metadata-url"` — + // `metadata-url` is never parsed as its own flag and stays unset. The + // TS CLI's own parser (unlike pflag) never hands a dash-prefixed token + // to a non-boolean flag as a bare value, so here both flags resolve to + // `Option.none()` — but the raw-argv mutex scan must reach the same + // "not a violation" conclusion pflag does, not double-count the + // `--metadata-url` token as a second explicit flag. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--metadata-file", "--metadata-url"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: a bare --add-domains followed by --domains=... is not a violation", () => { + // Same consumed-value class as the metadata-file/metadata-url case + // above, but for the domains group: pflag would hand `add-domains` the + // literal value `"--domains=x.com"` and never parse `--domains` at all. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--add-domains", "--domains=x.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 089b2340c3..bc43278256 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | +| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | ## API Routes @@ -18,67 +20,186 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither this command nor any of its dependencies make a Management API call — everything is +resolved from local `config.toml` and the local Docker daemon. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| `SUPABASE_AUTH_JWT_SECRET` | overrides `auth.jwt_secret` | no | +| `SUPABASE_AUTH_PUBLISHABLE_KEY` | overrides `auth.publishable_key` | no | +| `SUPABASE_AUTH_SECRET_KEY` | overrides `auth.secret_key` | no | +| `SUPABASE_AUTH_ANON_KEY` | overrides `auth.anon_key` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | overrides `auth.service_role_key` | no | + +The `SUPABASE_AUTH_*` vars mirror Go's Viper `AutomaticEnv` (`SetEnvPrefix("SUPABASE")` + +`.`→`_` key replacer, `pkg/config/config.go:529-535`) and take precedence over the corresponding +`config.toml` value, matching Viper's real precedence order. + +`docker` (or `podman` as a fallback) must be on `PATH`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — status displayed | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success — status displayed | +| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | +| `1` | `supabase/config.toml` missing or malformed | +| `1` | a malformed `--override-name` entry | +| `1` | listing running containers failed (Docker daemon unreachable, etc.) | +| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | +| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | +| `1` | `api.enabled` and `api.tls.enabled` are true and only one of `api.tls.cert_path`/`key_path` is set (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `api.enabled` and `api.tls.enabled` are true, both `cert_path` and `key_path` are set, but one of the files can't be read | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a table of service names, container IDs, images, and URLs. +Default (`-o` unset or `-o pretty`): a stderr banner, then 5 grouped rounded-border tables on +stdout. Empty rows (a value with nothing resolved) and entirely empty groups are skipped; a +blank line follows every group, rendered or not. ``` - supabase local development setup is running. - - API URL: http://127.0.0.1:54321 - GraphQL URL: http://127.0.0.1:54321/graphql/v1 - S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3 - DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres - Studio URL: http://127.0.0.1:54323 - Inbucket URL: http://127.0.0.1:54324 - JWT secret: super-secret-jwt-token-with-at-least-32-characters-long - anon key: ... -service_role key: ... - S3 Access Key: 625729a08b95bf1b7ff351a663f3a23c - S3 Secret Key: 850181e4652dd023b7a98c58ae0d2d34bd487ee0ead3abe0 - S3 Region: local +supabase local development setup is running. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │ +│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │ +╰─────────────┴────────────────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────────────────────╮ +│ 📦 Storage (S3) │ +├────────────┬──────────────────────────────────────────────────────────────────┤ +│ URL │ http://127.0.0.1:54321/storage/v1/s3 │ +│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │ +│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │ +│ Region │ local │ +╰────────────┴──────────────────────────────────────────────────────────────────╯ ``` -### `--output-format json` +Group table cells are colored on a TTY (Aqua for links, Yellow for keys, Green for labels, bold +headers); colors are stripped on non-TTY/piped output. + +`Stopped services: [ ...]` is written to stderr (Go slice format, e.g. +`[supabase_storage_test supabase_studio_test]`) whenever one of the 13 expected service +containers isn't in the running set. + +### `-o env` + +`KEY="VALUE"` lines (unquoted for integer-looking values), one per resolved field, sorted by +key — see `legacy-go-output.encoders.ts`'s `encodeEnv`. + +### `-o json` ```json { "API_URL": "http://127.0.0.1:54321", - "DB_URL": "postgresql://...", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", "ANON_KEY": "...", "SERVICE_ROLE_KEY": "...", + "PUBLISHABLE_KEY": "...", + "SECRET_KEY": "...", "JWT_SECRET": "...", - "S3_ACCESS_KEY": "...", - "S3_SECRET_KEY": "...", - "S3_REGION": "local" + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "...", + "S3_PROTOCOL_REGION": "local" } ``` -### `--output-format stream-json` +Top-level keys sorted alphabetically, 2-space indent, trailing newline (Go `encoding/json` +parity). Fields whose owning service is disabled or excluded are omitted entirely (not emitted +as `null`/`""`). + +### `-o yaml` / `-o toml` + +Same value set as `-o json`, encoded via `encodeYaml`/`encodeToml`. + +### `--output-format json` / `stream-json` (when `-o` is unset or `pretty`) -Not applicable. +Additive — no Go CLI equivalent. Emits the same resolved value map via +`output.success("", values)` / the NDJSON `result` event. ## Notes -- `--override-name` flag overrides specific variable names in env output. -- `-o env` output format uses KEY=VALUE pairs. -- `-o json` output format uses a JSON object. -- `-o pretty` (default) uses the human-readable table format. -- `--exclude` (hidden, repeatable) omits named containers from the output; matches Go's `cmd/status.go:39-40`. -- `--ignore-health-check` (hidden, boolean) exits `0` even when a service is unhealthy; matches Go's `cmd/status.go:41-42`. +- `-o`/`--output` (`env|pretty|json|toml|yaml`) takes priority over `--output-format` whenever + it is set, matching the Go-parity checklist's dual-output-flag rule. `-o pretty` (or `-o` + unset) falls through to `--output-format`'s text/json/stream-json handling. +- `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` remaps a single field's output KEY; the + value and group layout are unaffected. An unknown key or a malformed (non `KEY=VALUE`) entry + fails with `LegacyStatusOverrideParseError`. This only affects the `env`/`json`/`toml`/`yaml` + (`printStatus`) output path — matching Go, the pretty table (`-o pretty` or unset) always + renders with un-overridden names, since Go's `PrettyPrint` unmarshals a fresh, empty `EnvSet{}` + rather than reusing the CLI-supplied, override-populated `CustomName` (`status.go:236-243`). +- When neither `docker` nor `podman` can be spawned at all, the error message names the actual + root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or + Podman and ensure it is on PATH") rather than a generic "failed to ..." string. +- `--exclude ` (hidden) omits a service from the value map when `value` matches either its + container id or its default Docker image short name (Go's `ShortContainerImageName`, e.g. + `storage-api` for the storage service, `edge-runtime` for edge functions) — the default image + is read from the same embedded Dockerfile manifest Go parses, so a version bump there is picked + up automatically without needing to read the `.temp/-version` pin file. +- `--ignore-health-check` (hidden) skips the db container health assertion entirely and always + exits `0`, matching Go's early-return in `Run()`. +- Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a + Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the + latter uses a different issuer, expiry, and claim order that would not match what Go prints + for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command + (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load + time before any command can render output. +- When `auth.signing_keys_path` is set and resolves to a non-empty JWK array, `anon_key`/ + `service_role_key` are instead signed asymmetrically (RS256/ES256) with the file's first key, + matching Go's `generateJWT` (`pkg/config/apikeys.go:76-113`) — a relative path resolves against + `/supabase`. This path is skipped entirely when `auth.anon_key`/`auth.service_role_key` + are explicitly configured. A missing/malformed file, or a first key with an algorithm other than + `RS256`/`ES256`, fails the command (`LegacyStatusInvalidConfigError`). +- `SUPABASE_AUTH_JWT_SECRET`/`SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY`/ + `SUPABASE_AUTH_ANON_KEY`/`SUPABASE_AUTH_SERVICE_ROLE_KEY` override the corresponding + `config.toml` value at higher precedence, matching Go's Viper `AutomaticEnv` — an empty env var + is treated as unset. This is scoped to exactly the 5 auth fields `status` reads; it is not a + general `@supabase/config` port of Viper's `AutomaticEnv` (which applies to every config field). +- `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; + Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced + identically in `legacy-local-config-values.ts`. +- No e2e test is planned for this command: there is no Docker-daemon-free golden path, and the + e2e harness (`runSupabase()`) does not provision a real local stack. This is a scope reduction + relative to the Linear issue's "E2E compatibility test added" checkbox; see the port plan for + the full justification. diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index 201bce98f0..12be889ebb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -1,19 +1,47 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyParseStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; -const config = { - overrideName: Flag.string("override-name").pipe( - Flag.atLeast(0), - Flag.withDescription("Override specific variable names."), - Flag.withDefault([] as ReadonlyArray), - ), - exclude: Flag.string("exclude").pipe( +/** + * Go registers both `--override-name` and `--exclude` as pflag `StringSliceVar` + * (`cmd/status.go:36-37`), which CSV-splits each occurrence and accumulates + * across repeats — `--override-name a=1,b=2` is two overrides, not one. Effect's + * `Flag.atLeast(0)` only handles repetition, so every occurrence needs the same + * `legacyParseStringSliceFlag` normalization already used for `sso`/`postgres-config`. + */ +function csvStringSliceFlag(name: string) { + return Flag.string(name).pipe( Flag.atLeast(0), - Flag.withDescription("Names of containers to omit from output."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.withDefault([] as ReadonlyArray), - Flag.withHidden, - ), + ); +} + +export const legacyStatusOverrideNameFlag = csvStringSliceFlag("override-name").pipe( + Flag.withDescription("Override specific variable names."), +); + +export const legacyStatusExcludeFlag = csvStringSliceFlag("exclude").pipe( + Flag.withDescription("Names of containers to omit from output."), + Flag.withHidden, +); + +const config = { + overrideName: legacyStatusOverrideNameFlag, + exclude: legacyStatusExcludeFlag, ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), Flag.withHidden, @@ -22,6 +50,18 @@ const config = { export type LegacyStatusFlags = CliCommand.Command.Config.Infer; +// `status` makes no Management API calls (Go's status needs no access token), so +// it deliberately avoids `legacyManagementApiRuntimeLayer` — mirrors `unlink`'s +// runtime shape. `legacyCliConfigLayer` is exposed at the top level directly +// (nothing else in this runtime needs to consume it internally). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStatusRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["status"]), +); + export const legacyStatusCommand = Command.make("status", config).pipe( Command.withDescription("Show status of local Supabase containers."), Command.withShortDescription("Show status of local Supabase containers"), @@ -35,5 +75,14 @@ export const legacyStatusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), - Command.withHandler((flags) => legacyStatus(flags)), + Command.withHandler((flags) => + legacyStatus(flags).pipe( + withLegacyCommandInstrumentation({ + flags, + outputFormats: LEGACY_RESOURCE_OUTPUT_FORMATS, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyStatusRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts new file mode 100644 index 0000000000..257f805382 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts @@ -0,0 +1,75 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyStatusExcludeFlag, legacyStatusOverrideNameFlag } from "./status.command.ts"; + +describe("legacy status --override-name flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple overrides", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual([]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("legacy status --exclude flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple exclusions", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual(["kong", "auth"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual([]); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts new file mode 100644 index 0000000000..9e72e14fbb --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -0,0 +1,58 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `status`'s own `PreRunE` + * (override-name parsing) or `RunE`, so a bad explicit workdir must fail here + * first, before config load or any Docker access. + */ +export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ + readonly message: string; +}> {} + +/** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ + readonly message: string; +}> {} + +/** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ +export class LegacyStatusOverrideParseError extends Data.TaggedError( + "LegacyStatusOverrideParseError", +)<{ + readonly message: string; +}> {} + +/** Inspecting the db container failed for a reason other than "not found". */ +export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ + readonly message: string; +}> {} + +/** The db container is absent or present but not in the `running` state. */ +export class LegacyStatusDbNotRunningError extends Data.TaggedError( + "LegacyStatusDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** The db container is running but its Docker health check is not `healthy`. */ +export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ + readonly message: string; +}> {} + +/** Listing running containers by label failed. */ +export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ + readonly message: string; +}> {} + +/** + * `config.toml` resolved to a value `Config.Validate` would reject before status + * ever renders — e.g. an `auth.jwt_secret` shorter than 16 characters + * (`pkg/config/apikeys.go:45-47`). + */ +export class LegacyStatusInvalidConfigError extends Data.TaggedError( + "LegacyStatusInvalidConfigError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 566427260c..7e984a0f93 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,12 +1,350 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Schema } from "effect"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyInspectContainerState, + legacyListContainersByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../shared/legacy-go-output.encoders.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { + LegacyStatusConfigLoadError, + LegacyStatusDbInspectError, + LegacyStatusDbNotReadyError, + LegacyStatusDbNotRunningError, + LegacyStatusInvalidConfigError, + LegacyStatusListError, + LegacyStatusOverrideParseError, + LegacyStatusWorkdirError, +} from "./status.errors.ts"; +import { legacyRenderStatusPretty } from "./status.pretty.ts"; +import { + LEGACY_STATUS_FIELDS, + legacyGateStatusState, + legacyResolveStatusLocalState, + legacyStatusContainerIds, + legacyStatusValuesFromState, +} from "./status.values.ts"; + +/** + * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a + * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` + * pair. `env.EnvironToEnvSet` only validates that shape (`go-env`'s + * `ErrInvalidEnviron`); the Netflix `go-env` library's `Unmarshal` then walks + * `CustomName`'s own struct fields and looks up each field's tag in the + * resulting map — it never checks the map for leftover/unmatched keys, so an + * entry whose `KEY` isn't one of the 18 known `CustomName` field keys is + * silently ignored, not an error (verified against `go-env@v0.1.2`'s + * `env.go`/`transform.go`). + */ +function parseOverrides( + entries: ReadonlyArray, +): Effect.Effect, LegacyStatusOverrideParseError> { + const knownKeys = new Set(LEGACY_STATUS_FIELDS.map((field) => field.fieldKey)); + const overrides = new Map(); + for (const entry of entries) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, + }), + ); + } + const key = entry.slice(0, separatorIndex); + const value = entry.slice(separatorIndex + 1); + if (!knownKeys.has(key)) { + continue; + } + overrides.set(key, value); + } + return Effect.succeed(overrides); +} + +/** Go's `fmt.Fprintln(os.Stderr, "Stopped services:", stopped)` slice format. */ +function formatGoStringSlice(items: ReadonlyArray): string { + return `[${items.join(" ")}]`; +} export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyStatusFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["status"]; - for (const override of flags.overrideName) args.push("--override-name", override); - for (const name of flags.exclude) args.push("--exclude", name); - if (flags.ignoreHealthCheck) args.push("--ignore-health-check"); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // 0. Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before `status`'s own + // `PreRunE` (override-name parsing) or `RunE`. A missing or non-directory + // path fails immediately, so this must win over every later error. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStatusWorkdirError({ message: error.message })), + ); + + // 1. `--override-name KEY=VALUE` parsing — mirroring Go's Cobra wiring, + // where override validation runs in `PreRunE` (`cmd/status.go:21-27`) and + // Cobra's execute loop returns as soon as `PreRunE` errors, never calling + // `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a malformed + // `--override-name` entry fails before `status.Run` ever loads config or + // touches Docker (`internal/status/status.go:101-116`) — it must win over + // a config-load error or a Docker/DB health-check error, not be masked by + // either. `overrides` itself is only consumed much later, by + // `legacyStatusValuesFromState` below. + const overrides = yield* parseOverrides(flags.overrideName); + + // 2. `status` always needs config, unlike `stop` (status.go:99-103). An + // ABSENT config.toml is not a hard failure in Go: `flags.LoadConfig` -> + // `Config.Load` -> `loadFromFile` -> `mergeFileConfig` treats a missing + // file as a no-op (`os.ErrNotExist` -> nil, pkg/config/config.go:655-656) + // and proceeds with template defaults (`mergeDefaultValues`, + // pkg/config/config.go:639-648). Only a MALFORMED file is a hard error. + // Mirror that by decoding an empty document through the schema for its + // defaults (matching `packages/config/src/functions-manifest.ts`'s + // `decodeProjectConfig({})` pattern) instead of failing. + // `search: false` on both loaders below: `cliConfig.workdir` already IS + // Go's fully-resolved chdir target (`legacy-cli-config.layer.ts`'s + // `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default- + // searched resolution, `apps/cli-go/internal/utils/misc.go:231-247`), so + // letting `@supabase/config`'s `findProjectPaths` climb ancestors again on + // top of that would let an unrelated ancestor project's config.toml win + // when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Resolved BEFORE `loadProjectConfig` decodes + // config.toml (not after) because Go's `Config.Load` runs `loadNestedEnv` + // before `LoadEnvHook` decodes `env(...)` references (`config.go:735-738`); + // an `env(...)` value sourced only from a project-root/`SUPABASE_ENV`- + // selected file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID`/`SUPABASE_AUTH_*` overrides read further below. + // A malformed extra dotenv file throws here (see `readDotEnvFile`), + // matching Go's `loadNestedEnv` propagating `godotenv`'s parse error + // instead of silently skipping the bad line. `workdir` is passed through so + // dotenv files under `/supabase`/`workdir` are still discovered + // even when `projectEnv` is `null` (no config.toml there) — Go's own + // `loadNestedEnv` runs unconditionally, before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + catch: (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, reporting ports/keys for a config Go never reads. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // 3. Resolve + VALIDATE config-derived state before any Docker call — + // matching Go's `flags.LoadConfig` (config load + `Validate`, + // `internal/utils/flags/config_path.go:12` -> `pkg/config/config.go:882`), + // which runs entirely before `assertContainerHealthy`/container listing + // (`internal/status/status.go:101-116`). `legacyResolveStatusLocalState` + // can throw `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`), + // `LegacyInvalidPortEnvOverrideError`/`LegacyInvalidBoolEnvOverrideError` + // (a malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override), or a + // signing-keys-file read/parse error — all of these must fail here, not + // be masked by a Docker/DB error when the local stack happens to be + // unavailable. `hostname` has no Docker dependency either, so it's + // resolved here rather than later. + const hostname = legacyGetHostname(); + const localState = yield* Effect.try({ + try: () => + legacyResolveStatusLocalState( + config, + hostname, + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + // 4. status has no --project-id flag; resolution is always env → toml → + // workdir basename, then sanitized to match the singleton Go's + // `Config.Validate` produces once at config-load time + // (`pkg/config/config.go:938-944`) — every reader, including the Docker + // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same + // sanitized string, so `status` must filter on it too (see + // `legacyCliProjectFilterValue`'s doc comment). + const projectId = legacySanitizeProjectId( + legacyResolveLocalProjectId( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ), + ); + const dbContainerId = localDbContainerId(projectId); + + // 5. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // Go's `assertContainerHealthy` never special-cases "not found" — an absent + // container fails `ContainerInspect` itself, which surfaces as the generic + // inspect error (status.go:147-150), not the "not running" branch (which + // only applies to a present-but-stopped container, status.go:150-151). + // `legacyInspectContainerState` mirrors that: a missing container is just + // another non-zero exit, mapped below with the real Docker stderr text. + if (!flags.ignoreHealthCheck) { + const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( + Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), + ); + if (!state.running) { + return yield* Effect.fail( + new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }), + ); + } + if (state.health !== undefined && state.health !== "healthy") { + return yield* Effect.fail( + new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }), + ); + } + } + + // 6. List running containers, diff against the 13 expected service ids + // (status.go:125-145), and report any that are stopped. + const filterValue = legacyCliProjectFilterValue(projectId); + const runningNames = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: false, + format: "names", + }).pipe(Effect.mapError((cause) => new LegacyStatusListError({ message: cause.message }))); + const runningSet = new Set(runningNames); + const serviceIds = legacyServiceContainerIds(projectId); + const stopped = serviceIds.filter((id) => !runningSet.has(id)); + if (stopped.length > 0) { + yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); + } + + // 7. Merge health-derived exclusions with the user's --exclude flag. + const excluded = [...stopped, ...flags.exclude]; + + // 8. Apply the exclude-based gating on top of the already-validated + // `localState` (Go's `toValues()` exclude filtering, `status.go:55-61`). + // Pure/non-throwing — see `legacyGateStatusState`'s doc comment. Reused + // for both the real and pretty-mode (empty-override) value maps below, + // matching this handler's pre-split behavior. + const containerIds = legacyStatusContainerIds(projectId); + const state = legacyGateStatusState(localState, containerIds, excluded); + const { values } = legacyStatusValuesFromState(state, overrides); + + // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty + // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the + // CLI-supplied, override-populated `names` — `--override-name` only ever + // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. + // Remap names from the already-resolved `state` (empty override map) so the + // rendered table matches Go exactly without leaking `--override-name` into + // pretty-mode output, and without a second (throwing) state resolution. + const renderPretty = Effect.fnUntraced(function* () { + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + const pretty = legacyStatusValuesFromState(state, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }); + + // 9. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete + // format choice and takes priority over --output-format (root.ts:119-121, + // matching functions/list's list.handler.ts:115-118) — only an ABSENT -o + // defers to --output-format for json/stream-json. + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + yield* output.raw(encodeEnv(values) + "\n"); + return; + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(values)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(values) + "\n"); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(values)); + return; + } + if (goFmt === "pretty") { + yield* renderPretty(); + return; + } + + // goFmt is undefined — defer to TS --output-format for json/stream-json, + // otherwise render the grouped rounded-table (Go's `-o pretty` default). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", values); + return; + } + + yield* renderPretty(); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 1c87049978..d1988d5569 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,58 +1,958 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; -import { legacyStatus } from "./status.handler.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, vi } from "vitest"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { legacyStatus } from "./status.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); + +afterEach(() => { + delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +}); + +function flags(overrides: Partial = {}): LegacyStatusFlags { + return { + overrideName: [], + exclude: [], + ignoreHealthCheck: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** Same routing-by-argv mock spawner shape as `stop.integration.test.ts`. */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } -function setupLegacyStatus() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(5000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); +const HEALTHY_DB_STATE = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, +}); + +/** + * Default happy-path router: db container inspect reports healthy+running, `ps` + * (names format) lists every one of the 13 expected services as running. + */ +function defaultRoute( + opts: { + readonly runningNames?: ReadonlyArray; + readonly dbInspectStdout?: string; + readonly dbInspectExitCode?: number; + readonly dbInspectStderr?: ReadonlyArray; + } = {}, +) { + const runningNames = opts.runningNames ?? ALL_RUNNING_NAMES; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "container" && args[1] === "inspect") { + return { + exitCode: opts.dbInspectExitCode ?? 0, + stdout: [opts.dbInspectStdout ?? HEALTHY_DB_STATE], + stderr: opts.dbInspectStderr, + }; + } + if (args[0] === "ps") return { stdout: runningNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly skipConfig?: boolean; + readonly configContents?: string; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.workdir ?? tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configContents); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStatusFlags = { - overrideName: [], - exclude: [], - ignoreHealthCheck: false, -}; +describe("legacy status integration", () => { + it.live("shows the running stack as a pretty table", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🔧 Development Tools"); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.stdoutText).toContain("⛁ Database"); + expect(out.stdoutText).toContain("🔑 Authentication Keys"); + expect(out.stdoutText).toContain("📦 Storage (S3)"); + expect(out.stdoutText).toContain("postgresql://postgres:postgres@"); + expect(out.stderrText).not.toContain("Stopped services:"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering/inspecting with the raw value here would target + // containers `start` never created. + const { layer, child } = setup({ configContents: 'project_id = "My App!!"\n' }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args[2]).toBe(localDbContainerId("My_App_")); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=My_App_"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("skips the db health check with --ignore-health-check", () => { + const { layer, child } = setup({ + route: (args) => { + // db inspect would fail if called; ps still needs to succeed. + if (args[0] === "container" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "ps") return { stdout: ALL_RUNNING_NAMES }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect")).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", + () => { + // Pairs with "fails when the db container is unhealthy" below (ignoreHealthCheck: false, + // the default) to cover both sides of Go's `if !ignoreHealthCheck { assertContainerHealthy }`. + const { layer, child } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect( + child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect"), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("reports stopped services on stderr", () => { + const { layer, out } = setup({ + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const missing = ALL_RUNNING_NAMES[0]; + expect(out.stderrText).toContain(`Stopped services: [${missing}]`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); -describe("legacy status", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // `status` never binds a --project-ref flag, so it must still fail on a + // config-wide duplicate, before ever reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "previewrefaaaaaaaaaa" + +[remotes.b] +project_id = "previewrefaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus(baseFlags); - expect(calls).toEqual([["status"]]); + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --override-name for each provided override", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only a + // remote that ends up selected — so this must fail closed before status + // reaches Docker, even with no --project-ref requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus({ - ...baseFlags, - overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"], + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "decodes a comma-separated string into an array field ([]string) for status to proceed", + () => { + // Go's StringToSliceHookFunc (mapstructure) splits a plain string literal + // into a []string for a []string field like additional_redirect_urls — + // this only runs when goViperCompat is on. Pin that status still proceeds + // past config load (and on to a successful Docker inspect/list) rather + // than treating the string literal as a decode error. + const { layer } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', }); - expect(calls).toEqual([["status", "--override-name", "api.url=NEXT_PUBLIC_SUPABASE_URL"]]); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // Go's `external.validate()` (config.go:1418-1423) disables a bare + // [auth.external.slack] block and warns — mirrored by + // `normalizeDeprecatedExternalProviders` in packages/config's io.ts, gated + // on `goViperCompat` (confirmed already wired in status.handler.ts). The + // WARN goes out via Effect's `Console.error`, not this file's `Output` + // service, so it must be observed with a raw console.error spy — same + // idiom as packages/config/src/io.unit.test.ts's deprecated-provider tests. + const { layer } = setup({ + configContents: 'project_id = "demo"\n[auth.external.slack]\nenabled = true\n', + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: disabling deprecated "slack" provider'), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before config + // load or any Docker call — a missing path must fail immediately, not + // fall through to the workdir-basename default and inspect Docker. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { + // Go's Config.Validate rejects this at config-load time + // (pkg/config/apikeys.go:45-47), entirely before assertContainerHealthy/ + // container listing (internal/status/status.go:101-116) — so no Docker + // call happens, same as the malformed config.toml case above. + const { layer, child } = setup({ + configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusInvalidConfigError"); + expect(JSON.stringify(exit.cause)).toContain( + "Invalid config for auth.jwt_secret. Must be at least 16 characters", + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { + // Go's Viper AutomaticEnv gives env vars higher precedence than config.toml + // (pkg/config/config.go:529-535) — a stack started with this env var set + // must report the env-derived secret, not the one in config.toml. + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"b".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { + // Go's generateJWT signs with the first key in auth.signing_keys_path + // (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK + // array (pkg/config/apikeys.go:76-113). + const { layer, out, workdir } = setup({ + goOutput: Option.some("json"), + configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', + }); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; + writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + const [headerSegment] = parsed.ANON_KEY?.split(".") ?? []; + const header = JSON.parse(Buffer.from(headerSegment ?? "", "base64url").toString()); + expect(header).toEqual({ alg: "RS256", kid: "test-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports status using schema defaults when config.toml is missing entirely", () => { + // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> + // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> + // nil, pkg/config/config.go:655-656), not an error — `status` proceeds + // using template defaults. Only a malformed file is a hard failure (see + // the sibling "malformed" test above). + // + // Without config.toml, the resolved project id falls back to the workdir + // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to + // "demo") — route `ps` off that so the expected services actually show as + // running rather than all appearing "stopped" and excluded. + const projectId = basename(tempRoot.current); + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("Project URL"); + expect(out.stdoutText).toContain("Database"); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("env-file-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("env-file-project")); }).pipe(Effect.provide(layer)); }); - it.live("forwards the hidden --exclude and --ignore-health-check flags", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("ambient-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("ambient-project")); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("root-env-project") }), + }); return Effect.gen(function* () { - yield* legacyStatus({ - overrideName: [], - exclude: ["db", "kong"], - ignoreHealthCheck: true, + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("root-env-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), }); - expect(calls).toEqual([ - ["status", "--exclude", "db", "--exclude", "kong", "--ignore-health-check"], - ]); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId(projectId)); + expect(inspectCall?.args).not.toContain(localDbContainerId("ancestor-project")); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); + const { layer, child } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds("no-config-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("no-config-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before AutomaticEnv reads SUPABASE_AUTH_JWT_SECRET (config.go:735-738) — + // a dotenv-file-only value must be visible here too, not just an ambient + // shell export (see the sibling "-o env" ambient test above). + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when both docker and podman are missing", () => { + // Neither container runtime can be spawned at all — distinct from a spawned + // process exiting non-zero (covered by the malformed/unhealthy scenarios + // above). + const { layer } = setup({ failSpawnFor: () => true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ dockerMissing: true }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the last + // matching record for a given argv is the successful one. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when listing running containers errors", () => { + const { layer } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: [HEALTHY_DB_STATE] }; + } + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is not running", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "exited", Running: false }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbNotRunningError"); + expect(serialized).toContain(localDbContainerId("demo")); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) gates on the boolean + // `resp.State.Running`, not the status string — a paused container can + // report `Running: true` alongside `Status: "paused"`, and Go continues + // past the not-running branch to the health check in that case. + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "paused", + Running: true, + Health: { Status: "healthy" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it wraps + // whatever `ContainerInspect` returns (`status.go:148-149`), so the real + // Docker stderr must flow through rather than a hardcoded TS string. + const { layer } = setup({ + route: defaultRoute({ + dbInspectExitCode: 1, + dbInspectStderr: ["Error response from daemon: No such container: x"], + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbInspectError"); + expect(serialized).toContain( + "failed to inspect container health: Error response from daemon: No such container: x", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is unhealthy", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when db inspect errors for a reason other than not-found", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectExitCode: 1, dbInspectStderr: ["permission denied"] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs env vars with -o env", () => { + const { layer, out } = setup({ goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).toContain("DB_URL="); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs a json object with -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.DB_URL).toContain("postgresql://postgres:postgres@"); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits excluded services from -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits every service named across multiple --exclude entries", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [authId, storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { + // Go's `excluded := append(stopped, exclude...)` merges the health-derived + // stopped list with the user-supplied --exclude list — both must take effect + // together, not just whichever one the command would have applied alone. + const { layer, out } = setup({ + goOutput: Option.some("json"), + // kong (index 0) is absent from the running set, so it's auto-detected as stopped. + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + yield* legacyStatus(flags({ exclude: [authId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude + expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs yaml with -o yaml", () => { + const { layer, out } = setup({ goOutput: Option.some("yaml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs toml with -o toml", () => { + const { layer, out } = setup({ goOutput: Option.some("toml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL ="); + }).pipe(Effect.provide(layer)); + }); + + it.live("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.API_URL).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on a malformed --override-name entry", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags({ overrideName: ["not-a-kv-pair"] }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("silently ignores an --override-name entry with an unknown field key", () => { + // Matches Go: `env.Unmarshal` (Netflix go-env) walks CustomName's own struct + // fields and looks up each field's tag in the override map — it never checks + // for leftover/unmatched keys, so an unrecognized key is a no-op, not an error. + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NAME).toBeUndefined(); + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + }).pipe(Effect.provide(layer)); + }); + + it.live("applies a valid --override-name entry alongside an unknown one", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus( + flags({ overrideName: ["not.a.real.field=NAME", "api.url=NEXT_PUBLIC_SUPABASE_URL"] }), + ); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.NAME).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result with --output-format json when -o is unset", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ API_URL: "http://127.0.0.1:54321" }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o takes priority over --output-format when both are passed", () => { + const { layer, out } = setup({ format: "json", goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // -o env wins: raw KEY="VALUE" text on stdout, not a structured success message. + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("lets --output pretty win over --output-format json", () => { + // Explicit `-o pretty` is a complete Go format choice (root.ts:119-121, + // matching functions/list) and must render the table, not defer to the + // TS-only --output-format json/stream-json branch. + const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" ? { exitCode: 1 } : { exitCode: 0 }, + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStatus(flags())); + expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts new file mode 100644 index 0000000000..64569c118c --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.live.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 280_000; + +// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" +// section for the full convention. +describeLive("supabase status (live)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "reports a running local stack in pretty and json modes", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/status/status.pretty.ts b/apps/cli/src/legacy/commands/status/status.pretty.ts new file mode 100644 index 0000000000..2999e4ef50 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.ts @@ -0,0 +1,276 @@ +import { legacyAqua, legacyBold, legacyGreen, legacyYellow } from "../../shared/legacy-colors.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +/** + * Port of Go's `PrettyPrint` / `OutputGroup.printTable` + * (`apps/cli-go/internal/status/status.go:236-392`), reproducing + * `tablewriter.NewTable` with `tw.StyleRounded` byte-for-byte for the fixed + * 5-group, 2-column layout `status` needs. This is not a general tablewriter + * port — column sizing, wrapping, and merge behavior are only implemented to the + * extent this command's rounded box needs them. + * + * Column 0 (the label column) is capped at 16 display columns + * (`ColMaxWidths.PerColumn[0] = 16`, `status.go:344`); a label wider than that + * word-wraps across multiple lines, leaving column 1 blank on the continuation + * lines (verified against a real `tablewriter@v1.1.4` render — see the port + * plan). None of the fixed labels below reach 17 characters today, so this is + * defensive parity rather than an observed case. + * + * This does not reuse `legacy/output/legacy-glamour-table.ts` — that helper + * byte-matches Go's `glamour.RenderTable(..., AsciiStyle)`, a single ASCII table + * with a different border style used by other commands. `status`'s Go source + * renders with `tablewriter`/`tw.StyleRounded` into 5 separate grouped, colored, + * Unicode-rounded-box tables, which is a different rendering contract entirely. + * + * Every color call below styles text written to **stdout** (via `output.raw` + * with no stream argument in `status.handler.ts`), so each one explicitly passes + * `process.stdout` to `legacy-colors.ts`'s helpers — they default to + * `process.stderr`, which would check the wrong stream's TTY status here. + */ + +type OutputKind = "text" | "link" | "key"; + +interface OutputItem { + readonly label: string; + readonly value: string; + readonly kind: OutputKind; +} + +interface OutputGroup { + readonly name: string; + readonly items: ReadonlyArray; +} + +const COLUMN_0_MAX_WIDTH = 16; + +/** + * Builds the 5 fixed groups Go's `PrettyPrint` declares (`status.go:245-285`), + * looking up each label's value by output KEY from the resolved value map — + * `--override-name` remaps the KEY but never the group layout, matching Go + * (`values[names.StudioURL]`, not a hardcoded default name). + */ +function buildGroups( + values: Readonly>, + names: LegacyStatusOutputNames, +): ReadonlyArray { + const at = (key: string) => values[key] ?? ""; + return [ + { + name: "🔧 Development Tools", + items: [ + { label: "Studio", value: at(names.studioUrl), kind: "link" }, + { label: "Mailpit", value: at(names.mailpitUrl), kind: "link" }, + { label: "MCP", value: at(names.mcpUrl), kind: "link" }, + ], + }, + { + name: "🌐 APIs", + items: [ + { label: "Project URL", value: at(names.apiUrl), kind: "link" }, + { label: "REST", value: at(names.restUrl), kind: "link" }, + { label: "GraphQL", value: at(names.graphqlUrl), kind: "link" }, + { label: "Edge Functions", value: at(names.functionsUrl), kind: "link" }, + ], + }, + { + name: "⛁ Database", + items: [{ label: "URL", value: at(names.dbUrl), kind: "link" }], + }, + { + name: "🔑 Authentication Keys", + items: [ + { label: "Publishable", value: at(names.publishableKey), kind: "key" }, + { label: "Secret", value: at(names.secretKey), kind: "key" }, + ], + }, + { + name: "📦 Storage (S3)", + items: [ + { label: "URL", value: at(names.storageS3Url), kind: "link" }, + { label: "Access Key", value: at(names.storageS3AccessKeyId), kind: "key" }, + { label: "Secret Key", value: at(names.storageS3SecretAccessKey), kind: "key" }, + { label: "Region", value: at(names.storageS3Region), kind: "text" }, + ], + }, + ]; +} + +/** + * Display width, matching `go-runewidth`'s treatment closely enough for this + * command's inputs: URLs/keys/labels are always plain ASCII, so every rune is + * width 1. The only non-ASCII runes ever rendered are the 5 fixed group-title + * emoji, whose exact rendered widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} + * below rather than computed generically (avoids taking a full Unicode + * East-Asian-Width dependency for a 5-value constant table). + */ +function displayWidth(text: string): number { + return [...text].length; +} + +/** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ +const HEADER_DISPLAY_WIDTH: Readonly> = { + "🔧 Development Tools": 20, + "🌐 APIs": 7, + "⛁ Database": 10, + "🔑 Authentication Keys": 22, + "📦 Storage (S3)": 15, +}; + +/** + * Exported only for direct unit coverage of the fallback branch (a group title + * outside the 5-entry {@link HEADER_DISPLAY_WIDTH} table) — every call site in + * this file only ever passes one of those 5 fixed titles. + */ +export function legacyStatusHeaderWidth(name: string): number { + return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); +} + +/** + * Greedy word-wrap to `width` columns, mirroring tablewriter's column wrapping. + * Exported only for direct unit coverage of the >16-char defensive-wrap branch + * (see the file-level doc comment) — none of this command's real labels reach + * that width today, so `legacyRenderStatusPretty` never exercises it end to end. + */ +export function legacyWrapStatusLabel(text: string, width: number): ReadonlyArray { + if (displayWidth(text) <= width) return [text]; + const words = text.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (displayWidth(candidate) <= width) { + current = candidate; + } else { + if (current.length > 0) lines.push(current); + current = word; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : [text]; +} + +/** + * Value coloring, mirroring the `switch row.Type` in `printTable` + * (`status.go:372-377`): `Link` → Aqua, `Key` → Yellow, `Text` → unstyled (the + * switch has no `Text` case, so `value` keeps its raw pre-switch assignment). + */ +function colorValue(kind: OutputKind, value: string): string { + switch (kind) { + case "link": + return legacyAqua(value, process.stdout); + case "key": + return legacyYellow(value, process.stdout); + case "text": + return value; + } +} + +interface ColumnLayout { + readonly col0Padded: number; + readonly col1Padded: number; + readonly targetInner: number; +} + +/** + * Computes the padded column widths and total inner (header) width for a group, + * mirroring tablewriter's column-sizing pass: each column is sized from its + * widest content cell (col 0 capped at 16), then both columns widen evenly + * (col 0 taking the larger half of an odd remainder) if the header text is + * wider than the data-driven layout. Exported only for direct unit coverage of + * the header-widens-the-table branch — none of this command's 5 fixed group + * titles are wider than their data today, so `legacyRenderStatusPretty` never + * exercises it end to end (see the file-level doc comment). + */ +export function legacyStatusColumnLayout( + headerWidthValue: number, + col0Contents: ReadonlyArray, + col1Contents: ReadonlyArray, +): ColumnLayout { + const col0Content = Math.min( + COLUMN_0_MAX_WIDTH, + Math.max(...col0Contents.map((text) => displayWidth(text))), + ); + const col1Content = Math.max(...col1Contents.map((text) => displayWidth(text))); + + let col0Padded = col0Content + 2; + let col1Padded = col1Content + 2; + const dataInner = col0Padded + 1 + col1Padded; + const targetInner = Math.max(dataInner, headerWidthValue + 2); + const extra = targetInner - dataInner; + if (extra > 0) { + col0Padded += Math.ceil(extra / 2); + col1Padded += Math.floor(extra / 2); + } + return { col0Padded, col1Padded, targetInner }; +} + +function renderGroupTable(group: OutputGroup): string | undefined { + const rows = group.items.filter((item) => item.value.length > 0); + if (rows.length === 0) return undefined; + + // Column 0 wraps at 16; column 1 is never capped (Go only sets PerColumn[0]). + // Kept as plain text here — color is applied only after padding, below, so an + // ANSI escape is never counted toward the padded display width. + const wrappedRows = rows.map((row) => ({ + lines: legacyWrapStatusLabel(row.label, COLUMN_0_MAX_WIDTH), + kind: row.kind, + value: row.value, + })); + + const { col0Padded, col1Padded, targetInner } = legacyStatusColumnLayout( + legacyStatusHeaderWidth(group.name), + rows.map((row) => row.label), + rows.map((row) => row.value), + ); + const col0Width = col0Padded - 2; + const col1Width = col1Padded - 2; + + // Pad on the plain text first, then apply color/bold — an active ANSI escape + // must never be counted toward the padded display width. + const pad = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - displayWidth(text))); + // The header uses `headerWidth` (the hardcoded emoji-aware width table) rather + // than `displayWidth`, so its padding lines up with the border math above, + // which sized `targetInner` off the same `headerWidth` call. + const padHeader = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - legacyStatusHeaderWidth(text))); + + const lines: string[] = []; + lines.push(`╭${"─".repeat(col0Padded + 1 + col1Padded)}╮`); + lines.push(`│ ${legacyBold(padHeader(group.name, targetInner - 2), process.stdout)} │`); + lines.push(`├${"─".repeat(col0Padded)}┬${"─".repeat(col1Padded)}┤`); + for (const row of wrappedRows) { + row.lines.forEach((line, index) => { + // Only the first wrapped line carries the value; Go's continuation lines + // (from a >16-char label wrapping) leave column 1 blank. + const labelCell = legacyGreen(pad(line, col0Width), process.stdout); + const paddedValue = pad(index === 0 ? row.value : "", col1Width); + const valueCell = index === 0 ? colorValue(row.kind, paddedValue) : paddedValue; + lines.push(`│ ${labelCell} │ ${valueCell} │`); + }); + } + lines.push(`╰${"─".repeat(col0Padded)}┴${"─".repeat(col1Padded)}╯`); + return lines.join("\n"); +} + +/** + * Port of Go's `PrettyPrint` (`status.go:236-294`): renders the 5 fixed groups + * as rounded-border tables, skipping empty rows and empty groups, with a blank + * line after every group (rendered or not — Go's loop always + * `fmt.Fprintln(w)`s after a nil-error `printTable`, even when nothing rendered). + */ +export function legacyRenderStatusPretty( + values: Readonly>, + names: LegacyStatusOutputNames, +): string { + const groups = buildGroups(values, names); + const lines: string[] = []; + for (const group of groups) { + const table = renderGroupTable(group); + if (table !== undefined) { + lines.push(table); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts new file mode 100644 index 0000000000..be44ac4c75 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyRenderStatusPretty, + legacyStatusColumnLayout, + legacyStatusHeaderWidth, + legacyWrapStatusLabel, +} from "./status.pretty.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which +// no-ops on a real non-TTY stream but the vitest process presents its stderr +// as color-capable. Strip escapes so these assertions target the plain +// structural output — the golden contract per the port plan — not whichever +// TTY heuristic the test runner happens to report. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +// Default (un-overridden) output names, matching `status.values.ts`'s +// `resolveOutputNames` with an empty override map — the KEYs the pretty +// renderer looks values up by. +const NAMES: LegacyStatusOutputNames = { + apiUrl: "API_URL", + restUrl: "REST_URL", + graphqlUrl: "GRAPHQL_URL", + storageS3Url: "STORAGE_S3_URL", + mcpUrl: "MCP_URL", + functionsUrl: "FUNCTIONS_URL", + dbUrl: "DB_URL", + studioUrl: "STUDIO_URL", + mailpitUrl: "MAILPIT_URL", + publishableKey: "PUBLISHABLE_KEY", + secretKey: "SECRET_KEY", + storageS3AccessKeyId: "S3_PROTOCOL_ACCESS_KEY_ID", + storageS3SecretAccessKey: "S3_PROTOCOL_ACCESS_KEY_SECRET", + storageS3Region: "S3_PROTOCOL_REGION", +}; + +const FULL_VALUES: Record = { + API_URL: "http://127.0.0.1:54321", + REST_URL: "http://127.0.0.1:54321/rest/v1", + GRAPHQL_URL: "http://127.0.0.1:54321/graphql/v1", + STORAGE_S3_URL: "http://127.0.0.1:54321/storage/v1/s3", + MCP_URL: "http://127.0.0.1:54321/mcp", + FUNCTIONS_URL: "http://127.0.0.1:54321/functions/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + STUDIO_URL: "http://127.0.0.1:54323", + MAILPIT_URL: "http://127.0.0.1:54324", + PUBLISHABLE_KEY: "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + SECRET_KEY: "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", + S3_PROTOCOL_ACCESS_KEY_SECRET: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + S3_PROTOCOL_REGION: "local", +}; + +describe("legacyRenderStatusPretty", () => { + // Byte-for-byte parity with a real `tablewriter@v1.1.4` + `tw.StyleRounded` + // render of Go's `PrettyPrint` group layout (verified by running the actual + // vendored Go module against this exact value set — see the port plan). + it("matches the Go rounded-table fixture for a fully running stack", () => { + const out = stripAnsi(legacyRenderStatusPretty(FULL_VALUES, NAMES)); + + const expected = [ + "╭──────────────────────────────────────╮", + "│ 🔧 Development Tools │", + "├─────────┬────────────────────────────┤", + "│ Studio │ http://127.0.0.1:54323 │", + "│ Mailpit │ http://127.0.0.1:54324 │", + "│ MCP │ http://127.0.0.1:54321/mcp │", + "╰─────────┴────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────╮", + "│ 🌐 APIs │", + "├────────────────┬─────────────────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "│ REST │ http://127.0.0.1:54321/rest/v1 │", + "│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │", + "│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │", + "╰────────────────┴─────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────────────╮", + "│ 🔑 Authentication Keys │", + "├─────────────┬────────────────────────────────────────────────┤", + "│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │", + "│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │", + "╰─────────────┴────────────────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────────────────────╮", + "│ 📦 Storage (S3) │", + "├────────────┬──────────────────────────────────────────────────────────────────┤", + "│ URL │ http://127.0.0.1:54321/storage/v1/s3 │", + "│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │", + "│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │", + "│ Region │ local │", + "╰────────────┴──────────────────────────────────────────────────────────────────╯", + "", + ].join("\n"); + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with a real render of a single-row group (Database), + // confirming the header-vs-single-short-row column sizing. All other groups + // are empty in this fixture, so only the Database box should appear. + it("matches the Go rounded-table fixture for a single-row group", () => { + const out = stripAnsi( + legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), + ); + + const expectedTable = [ + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + ].join("\n"); + + expect(out).toContain(expectedTable); + expect(out).toBe(["", "", expectedTable, "", "", ""].join("\n")); + }); + + // All other groups are empty in this fixture, so only the APIs box appears + // (only Project URL, the rest of the group's rows are excluded/disabled). + it("matches the Go rounded-table fixture for a partial APIs group", () => { + const out = stripAnsi(legacyRenderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); + + const expectedTable = [ + "╭──────────────────────────────────────╮", + "│ 🌐 APIs │", + "├─────────────┬────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "╰─────────────┴────────────────────────╯", + ].join("\n"); + + expect(out).toBe(["", expectedTable, "", "", "", ""].join("\n")); + }); + + it("skips a row whose value is missing from the value map", () => { + // Only Studio present; Mailpit/MCP absent from the map entirely (excluded + // or disabled upstream in `status.values.ts`) — same as an empty string. + const out = stripAnsi( + legacyRenderStatusPretty({ STUDIO_URL: "http://127.0.0.1:54323" }, NAMES), + ); + + expect(out).toContain("Studio"); + expect(out).not.toContain("Mailpit"); + expect(out).not.toContain("MCP"); + }); + + it("skips an entirely empty group but still emits its trailing blank line", () => { + // Nothing present for Development Tools; only the Database URL is set. + const out = stripAnsi(legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, NAMES)); + const lines = out.split("\n"); + + // No rounded-box characters before the Database group's own box. + expect(lines[0]).not.toMatch(/[╭│╰]/); + expect(lines[0]).toBe(""); + expect(out).not.toContain("Development Tools"); + expect(out).toContain("⛁ Database"); + }); + + it("returns only blank lines when every group is empty", () => { + const out = stripAnsi(legacyRenderStatusPretty({}, NAMES)); + // One blank line per group (5 groups), none of them rendering a table. + expect(out).toBe(["", "", "", "", ""].join("\n")); + }); + + // `legacyRenderStatusPretty` is a pure lookup: it renders whatever `values` + // are reachable through `names`' keys, with no opinion on how the caller + // derived either. This is NOT asserting that `--override-name` reaches + // pretty-mode output in production — `status.handler.ts` deliberately always + // calls this function with un-overridden names (matching Go's `PrettyPrint`, + // which unmarshals a fresh empty `EnvSet{}` rather than the CLI's overridden + // `CustomName`). This test only proves the renderer's KEY-based lookup itself + // works correctly for an arbitrary names/values pairing. + it("resolves values through whatever KEY the names parameter specifies", () => { + const overriddenNames: LegacyStatusOutputNames = { + ...NAMES, + apiUrl: "NEXT_PUBLIC_SUPABASE_URL", + }; + const out = stripAnsi( + legacyRenderStatusPretty( + { NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321" }, + overriddenNames, + ), + ); + expect(out).toContain("http://127.0.0.1:54321"); + }); +}); + +// None of `status`'s 18 fixed field labels or 5 fixed group titles are wide +// enough to exercise these two branches through the public +// `legacyRenderStatusPretty` API today (see the file-level doc comment on +// `status.pretty.ts`) — covered directly here as defensive Go-parity logic. +describe("legacyWrapStatusLabel", () => { + it("returns the text unwrapped when it fits within the width", () => { + expect(legacyWrapStatusLabel("Edge Functions", 16)).toEqual(["Edge Functions"]); + }); + + it("word-wraps a label wider than the column width", () => { + expect(legacyWrapStatusLabel("This Is A Very Long Label Name", 16)).toEqual([ + "This Is A Very", + "Long Label Name", + ]); + }); + + it("hard-breaks a single word wider than the column width", () => { + expect(legacyWrapStatusLabel("ThisIsAVeryLongSingleWordLabel", 16)).toEqual([ + "ThisIsAVeryLongSingleWordLabel", + ]); + }); + + it("does not emit a leading empty line when the very first word already overflows", () => { + expect(legacyWrapStatusLabel("SuperLongFirstWord Short", 10)).toEqual([ + "SuperLongFirstWord", + "Short", + ]); + }); + + it("returns the input unchanged for an empty label", () => { + expect(legacyWrapStatusLabel("", 10)).toEqual([""]); + }); + + it("returns the input unchanged for a whitespace-only label wider than the column", () => { + // Every "word" from splitting on spaces is itself empty, so `current` never + // accumulates anything to flush after the loop — the `lines` array stays + // empty and the function falls back to the original text. + expect(legacyWrapStatusLabel(" ", 2)).toEqual([" "]); + }); +}); + +describe("legacyStatusColumnLayout", () => { + it("sizes columns from content alone when the header already fits", () => { + const layout = legacyStatusColumnLayout(10, ["URL"], ["postgresql://short"]); + expect(layout.targetInner).toBe(3 + 2 + 1 + "postgresql://short".length + 2); + }); + + it("widens both columns evenly when the header is wider than the data", () => { + // Base data-driven layout: col0="a"(1+2=3), col1="b"(1+2=3), dataInner=3+1+3=7. + // A 10-char header needs innerWidth=12, so 5 extra columns split 3/2. + const layout = legacyStatusColumnLayout(10, ["a"], ["b"]); + expect(layout.targetInner).toBe(12); + expect(layout.col0Padded).toBe(6); + expect(layout.col1Padded).toBe(5); + }); + + it("caps column 0's content width at 16 even when a label is longer", () => { + const layout = legacyStatusColumnLayout(0, ["a".repeat(30)], ["b"]); + expect(layout.col0Padded).toBe(18); + }); +}); + +describe("legacyStatusHeaderWidth", () => { + it("uses the hardcoded emoji-aware width for a known fixed group title", () => { + expect(legacyStatusHeaderWidth("⛁ Database")).toBe(10); + }); + + it("falls back to code-point length for a title outside the fixed table", () => { + expect(legacyStatusHeaderWidth("Plain Title")).toBe("Plain Title".length); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts new file mode 100644 index 0000000000..4759a75af6 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -0,0 +1,495 @@ +import type { ProjectConfig } from "@supabase/config"; + +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; +import { + legacyEnvOverrideBool, + legacyResolveLocalConfigValues, + type LegacyLocalConfigValues, +} from "../../shared/legacy-local-config-values.ts"; + +/** + * Port of Go's `status.CustomName` + `toValues()` (`internal/status/status.go:29-97`). + * Each field's Go `env:"..."` tag carries two things: the dotted key + * `--override-name =` matches against (`fieldKey` below), and the + * default output env-var name (`defaultName`). `deprecated` fields (`inbucket`, + * `jwt_secret`, `anon_key`, `service_role_key`) are still emitted — Go's + * `deprecated` tag only affects a startup warning it never wires up for `status` + * (only `env.Unmarshal` reads the tag, and it does not warn), so no divergence here. + */ +export interface LegacyStatusField { + readonly fieldKey: string; + readonly defaultName: string; +} + +const API_URL: LegacyStatusField = { fieldKey: "api.url", defaultName: "API_URL" }; +const REST_URL: LegacyStatusField = { fieldKey: "api.rest_url", defaultName: "REST_URL" }; +const GRAPHQL_URL: LegacyStatusField = { fieldKey: "api.graphql_url", defaultName: "GRAPHQL_URL" }; +const STORAGE_S3_URL: LegacyStatusField = { + fieldKey: "api.storage_s3_url", + defaultName: "STORAGE_S3_URL", +}; +const MCP_URL: LegacyStatusField = { fieldKey: "api.mcp_url", defaultName: "MCP_URL" }; +const FUNCTIONS_URL: LegacyStatusField = { + fieldKey: "api.functions_url", + defaultName: "FUNCTIONS_URL", +}; +const DB_URL: LegacyStatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; +const STUDIO_URL: LegacyStatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; +const INBUCKET_URL: LegacyStatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; +const MAILPIT_URL: LegacyStatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; +const PUBLISHABLE_KEY: LegacyStatusField = { + fieldKey: "auth.publishable_key", + defaultName: "PUBLISHABLE_KEY", +}; +const SECRET_KEY: LegacyStatusField = { fieldKey: "auth.secret_key", defaultName: "SECRET_KEY" }; +const JWT_SECRET: LegacyStatusField = { fieldKey: "auth.jwt_secret", defaultName: "JWT_SECRET" }; +const ANON_KEY: LegacyStatusField = { fieldKey: "auth.anon_key", defaultName: "ANON_KEY" }; +const SERVICE_ROLE_KEY: LegacyStatusField = { + fieldKey: "auth.service_role_key", + defaultName: "SERVICE_ROLE_KEY", +}; +const STORAGE_S3_ACCESS_KEY_ID: LegacyStatusField = { + fieldKey: "storage.s3_access_key_id", + defaultName: "S3_PROTOCOL_ACCESS_KEY_ID", +}; +const STORAGE_S3_SECRET_ACCESS_KEY: LegacyStatusField = { + fieldKey: "storage.s3_secret_access_key", + defaultName: "S3_PROTOCOL_ACCESS_KEY_SECRET", +}; +const STORAGE_S3_REGION: LegacyStatusField = { + fieldKey: "storage.s3_region", + defaultName: "S3_PROTOCOL_REGION", +}; + +/** All 18 fields, in `CustomName` struct declaration order. */ +export const LEGACY_STATUS_FIELDS: ReadonlyArray = [ + API_URL, + REST_URL, + GRAPHQL_URL, + STORAGE_S3_URL, + MCP_URL, + FUNCTIONS_URL, + DB_URL, + STUDIO_URL, + INBUCKET_URL, + MAILPIT_URL, + PUBLISHABLE_KEY, + SECRET_KEY, + JWT_SECRET, + ANON_KEY, + SERVICE_ROLE_KEY, + STORAGE_S3_ACCESS_KEY_ID, + STORAGE_S3_SECRET_ACCESS_KEY, + STORAGE_S3_REGION, +]; + +/** The subset of {@link LEGACY_STATUS_FIELDS} the pretty renderer looks up by field. */ +export interface LegacyStatusOutputNames { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly storageS3Url: string; + readonly mcpUrl: string; + readonly functionsUrl: string; + readonly dbUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Resolves each field's output KEY, applying `--override-name =` + * remaps over the Go default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) + * to the replacement output name, mirroring `env.Unmarshal`'s `default=` override. + */ +function resolveOutputNames(overrides: ReadonlyMap): LegacyStatusOutputNames { + const nameFor = (field: LegacyStatusField) => overrides.get(field.fieldKey) ?? field.defaultName; + return { + apiUrl: nameFor(API_URL), + restUrl: nameFor(REST_URL), + graphqlUrl: nameFor(GRAPHQL_URL), + storageS3Url: nameFor(STORAGE_S3_URL), + mcpUrl: nameFor(MCP_URL), + functionsUrl: nameFor(FUNCTIONS_URL), + dbUrl: nameFor(DB_URL), + studioUrl: nameFor(STUDIO_URL), + mailpitUrl: nameFor(MAILPIT_URL), + publishableKey: nameFor(PUBLISHABLE_KEY), + secretKey: nameFor(SECRET_KEY), + storageS3AccessKeyId: nameFor(STORAGE_S3_ACCESS_KEY_ID), + storageS3SecretAccessKey: nameFor(STORAGE_S3_SECRET_ACCESS_KEY), + storageS3Region: nameFor(STORAGE_S3_REGION), + }; +} + +/** + * Container ids `toValues()` gates each group on, taken from + * `legacyServiceContainerIds`'s alias order (`kong`, `auth`, `inbucket`, ..., + * `edge_runtime`, ...) — see `legacy-docker-ids.ts`. + */ +export interface LegacyStatusContainerIds { + readonly kong: string; + readonly auth: string; + readonly inbucket: string; + readonly rest: string; + readonly storage: string; + readonly studio: string; + readonly edgeRuntime: string; +} + +// Positional indices into `legacyServiceContainerIds`'s fixed 13-element +// array (`legacy-docker-ids.ts`'s `GetDockerIds()` order), named so a caller +// never has to destructure the array positionally. +const CONTAINER_INDEX = { + kong: 0, + auth: 1, + inbucket: 2, + rest: 4, + storage: 5, + studio: 8, + edgeRuntime: 9, +} as const; + +/** + * Derives {@link LegacyStatusContainerIds} from `legacyServiceContainerIds`'s + * flat array for a given project id. The array's length and order are a fixed + * Go-parity contract (13 elements, `GetDockerIds()` order), so every named + * index here is guaranteed present — this only exists to give the handler a + * named-field view instead of positional array destructuring. + */ +export function legacyStatusContainerIds(projectId: string): LegacyStatusContainerIds { + const ids = legacyServiceContainerIds(projectId); + const at = (index: number) => ids[index] ?? ""; + return { + kong: at(CONTAINER_INDEX.kong), + auth: at(CONTAINER_INDEX.auth), + inbucket: at(CONTAINER_INDEX.inbucket), + rest: at(CONTAINER_INDEX.rest), + storage: at(CONTAINER_INDEX.storage), + studio: at(CONTAINER_INDEX.studio), + edgeRuntime: at(CONTAINER_INDEX.edgeRuntime), + }; +} + +/** + * Port of Go's `utils.ShortContainerImageName` (`internal/utils/misc.go:33-39,75`): + * extracts the repo name between the (first) `/` and the (last) `:`, falling back to + * the full string when the image ref doesn't match (no slash, or no tag). + */ +export function legacyShortContainerImageName(imageName: string): string { + const match = /\/(.*):/.exec(imageName); + return match?.[1] ?? imageName; +} + +// Default image short names Go's `--exclude` also matches against +// (`internal/status/status.go:55-61`), one per gated service. Sourced from the same +// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// there is picked up automatically. Pinned-version substitution +// (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the +// first `:`, which `legacyShortContainerImageName` discards — so these are invariant to +// version pinning and no `.temp/-version` file needs to be read here. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( + dockerfileServiceImage("edgeruntime"), +); + +export interface LegacyStatusValuesResult { + readonly values: Record; + readonly names: LegacyStatusOutputNames; + readonly local: LegacyLocalConfigValues; +} + +/** + * Everything `toValues()` needs that does NOT depend on `--override-name` — + * i.e. every field except the output KEY remapping. Resolving this once and + * reusing it for both the env/json/toml/yaml values (real overrides) and the + * pretty-table values (Go always recomputes with an empty override map, + * `status.go:236-243`) avoids re-reading `auth.signing_keys_path` and + * re-signing the anon/service_role JWTs a second time per invocation. + */ +export interface LegacyStatusState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly kongEnabled: boolean; + readonly postgrestEnabled: boolean; + readonly studioEnabled: boolean; + readonly authEnabled: boolean; + readonly inbucketEnabled: boolean; + readonly storageEnabled: boolean; + readonly functionsEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * The config-load/`Validate`-equivalent half of {@link LegacyStatusState} — + * everything that can THROW, and none of it depends on `excluded`/ + * `containerIds`. Split out so `status.handler.ts` can resolve and validate + * this before any Docker call, matching Go's `flags.LoadConfig` (config load + * + `Validate`, `internal/utils/flags/config_path.go:12` -> + * `pkg/config/config.go:882`) running entirely before `assertContainerHealthy`/ + * container listing (`internal/status/status.go:101-116`) — a bad + * `auth.jwt_secret` or malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` + * override must fail here, not be masked by a Docker/DB error when the local + * stack happens to be unavailable. + */ +export interface LegacyStatusLocalState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly apiEnabled: boolean; + readonly studioSectionEnabled: boolean; + readonly authSectionEnabled: boolean; + readonly inbucketSectionEnabled: boolean; + readonly storageSectionEnabled: boolean; + readonly edgeRuntimeEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * Port of the throwing, non-Docker-dependent half of Go's + * `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`): + * resolves local config values (URLs, keys — can throw, see + * {@link legacyResolveLocalConfigValues}) and the per-service `.enabled` gates, + * with NO reference to `excluded`/`containerIds` — see {@link legacyGateStatusState} + * for the Docker-dependent, non-throwing half this composes with (in + * `status.handler.ts`, or via {@link legacyStatusValues} for callers that + * don't need to run validation before Docker calls). + * + * Each `.enabled` gate is read through {@link legacyEnvOverrideBool}, not the + * raw decoded `config.

.enabled`, because Go's `status.toValues()` + * (`status.go:55-61`) reads `utils.Config.*.Enabled` — a package-level struct + * Viper has already applied any `SUPABASE_
_ENABLED` env/dotenv + * override to (`SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` + + * `ExperimentalBindStruct()`, `pkg/config/config.go:580-586`) — generically, + * not just for `auth.enabled`. Skipping this would mean a stack Go started + * with e.g. `SUPABASE_API_ENABLED=true` over a `false` TOML value has Kong/ + * PostgREST running while native `status` omits them entirely. + * + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. + */ +export function legacyResolveStatusLocalState( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusLocalState { + const local = legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const studioSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const authSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const inbucketSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const storageSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ); + const edgeRuntimeEnabled = legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + "edge_runtime.enabled", + projectEnvValues, + ); + const storageS3ProtocolEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", + projectEnvValues, + ); + + return { + config, + local, + apiEnabled, + studioSectionEnabled, + authSectionEnabled, + inbucketSectionEnabled, + storageSectionEnabled, + edgeRuntimeEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * The Docker-dependent, non-throwing half of Go's `toValues()`: applies + * `excluded` (matching each gated service by its container id + * (`legacyStatusContainerIds`) OR its default Docker image short name + * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields + * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, + * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never + * user-overridable and the default image is always the one to check) on top of + * an already-resolved {@link LegacyStatusLocalState}. Pure: every throwing + * concern already ran in {@link legacyResolveStatusLocalState}. + */ +export function legacyGateStatusState( + localState: LegacyStatusLocalState, + containerIds: LegacyStatusContainerIds, + excluded: ReadonlyArray, +): LegacyStatusState { + const { config, local } = localState; + const { apiEnabled, studioSectionEnabled, authSectionEnabled } = localState; + const { inbucketSectionEnabled, storageSectionEnabled } = localState; + const { edgeRuntimeEnabled, storageS3ProtocolEnabled } = localState; + const isExcluded = (id: string) => excluded.includes(id); + + const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const postgrestEnabled = + kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + const studioEnabled = + studioSectionEnabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + const authEnabled = + authSectionEnabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + const inbucketEnabled = + inbucketSectionEnabled && !isExcluded(containerIds.inbucket) && !isExcluded(MAILPIT_IMAGE_NAME); + const storageEnabled = + storageSectionEnabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + const functionsEnabled = + edgeRuntimeEnabled && + !isExcluded(containerIds.edgeRuntime) && + !isExcluded(EDGE_RUNTIME_IMAGE_NAME); + + return { + config, + local, + kongEnabled, + postgrestEnabled, + studioEnabled, + authEnabled, + inbucketEnabled, + storageEnabled, + functionsEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. + * Pure and non-throwing — every failure mode of `toValues()` lives in + * {@link legacyResolveStatusLocalState}, which runs once per `status` invocation. + */ +export function legacyStatusValuesFromState( + state: LegacyStatusState, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const { local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled, storageS3ProtocolEnabled } = state; + const names = resolveOutputNames(overrides); + + // Go always sets db.url unconditionally, before any gating (status.go:52). + const values: Record = { + [names.dbUrl]: local.dbUrl, + }; + + if (kongEnabled) { + values[names.apiUrl] = local.apiUrl; + if (postgrestEnabled) { + values[names.restUrl] = local.restUrl; + values[names.graphqlUrl] = local.graphqlUrl; + } + if (functionsEnabled) { + values[names.functionsUrl] = local.functionsUrl; + } + if (studioEnabled) { + values[names.mcpUrl] = local.mcpUrl; + } + } + if (studioEnabled) { + values[names.studioUrl] = local.studioUrl; + } + if (authEnabled) { + values[names.publishableKey] = local.publishableKey; + values[names.secretKey] = local.secretKey; + values[overrides.get(JWT_SECRET.fieldKey) ?? JWT_SECRET.defaultName] = local.jwtSecret; + values[overrides.get(ANON_KEY.fieldKey) ?? ANON_KEY.defaultName] = local.anonKey; + values[overrides.get(SERVICE_ROLE_KEY.fieldKey) ?? SERVICE_ROLE_KEY.defaultName] = + local.serviceRoleKey; + } + if (inbucketEnabled) { + values[names.mailpitUrl] = local.mailpitUrl; + values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; + } + if (storageEnabled && storageS3ProtocolEnabled) { + values[names.storageS3Url] = local.storageS3Url; + values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; + values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; + values[names.storageS3Region] = local.storageS3Region; + } + + return { values, names, local }; +} + +/** + * Convenience wrapper combining {@link legacyResolveStatusLocalState} + + * {@link legacyGateStatusState} + {@link legacyStatusValuesFromState} in one + * call — used directly by tests that only need a single override map. + * `status.handler.ts` calls the three separately instead, so it can resolve + + * validate `localState` before any Docker call (see + * {@link legacyResolveStatusLocalState}'s doc comment), and reuse the gated + * `state` for both the real and pretty-mode (empty-override) value maps + * without recomputing `local`. + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusValuesResult { + const localState = legacyResolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + const state = legacyGateStatusState(localState, containerIds, excluded); + return legacyStatusValuesFromState(state, overrides); +} diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts new file mode 100644 index 0000000000..3ae5f60004 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -0,0 +1,793 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + legacyShortContainerImageName, + legacyStatusContainerIds, + legacyStatusValues, + type LegacyStatusContainerIds, +} from "./status.values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +const CONTAINER_IDS: LegacyStatusContainerIds = { + kong: "supabase_kong_test", + auth: "supabase_auth_test", + inbucket: "supabase_inbucket_test", + rest: "supabase_rest_test", + storage: "supabase_storage_test", + studio: "supabase_studio_test", + edgeRuntime: "supabase_edge_runtime_test", +}; + +const HOSTNAME = "127.0.0.1"; +const NONE: ReadonlyArray = []; +const NO_OVERRIDES = new Map(); +const WORKDIR = "/tmp/status-values-test"; + +describe("legacyStatusValues", () => { + it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { + const config = baseConfig({ + api: { enabled: false }, + studio: { enabled: false }, + auth: { enabled: false }, + local_smtp: { enabled: false }, + storage: { enabled: false }, + edge_runtime: { enabled: false }, + }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(Object.keys(values)).toEqual(["DB_URL"]); + expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); + }); + + describe("api / kong gating", () => { + it("includes API_URL when api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + }); + + it("omits API_URL when api.enabled is false", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["kong"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when only the rest container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("includes REST/GraphQL when kong and postgrest are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.REST_URL).toBeDefined(); + expect(values.GRAPHQL_URL).toBeDefined(); + }); + + it("omits REST/GraphQL when the postgrest image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["postgrest"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + }); + + describe("functions gating", () => { + it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { + // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's + // build alias (`edgeruntime`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + }); + + describe("studio / mcp gating", () => { + it("includes STUDIO_URL when studio.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("omits STUDIO_URL when studio.enabled is false", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["studio"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("includes MCP_URL only when both kong and studio are enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeDefined(); + }); + + it("omits MCP_URL when kong is disabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeUndefined(); + }); + + it("omits MCP_URL when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MCP_URL).toBeUndefined(); + }); + }); + + describe("auth gating", () => { + it("includes all 5 auth fields when auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.SECRET_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits all 5 auth fields when auth.enabled is false", () => { + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + expect(values.SECRET_KEY).toBeUndefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the auth container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the gotrue image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["gotrue"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + }); + + describe("inbucket/mailpit gating", () => { + it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["mailpit"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + }); + + describe("storage / s3 gating", () => { + it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_SECRET).toBeDefined(); + expect(values.S3_PROTOCOL_REGION).toBeDefined(); + }); + + it("omits storage S3 fields when storage.enabled is false", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage-api image short name is excluded", () => { + // The image repo name (`supabase/storage-api`) differs from the Dockerfile's + // build alias (`storage`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["storage-api"], + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); + }); + }); + + describe("SUPABASE_*_ENABLED env overrides", () => { + // Go's `status.toValues()` (`status.go:55-61`) reads `utils.Config.*.Enabled` + // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding + // (`pkg/config/config.go:580-586`) has already applied any + // `SUPABASE_
_ENABLED` override — generically, not just for auth. + // `legacyResolveStatusLocalState` must read the same post-override value + // for every gate, not the raw decoded `config.
.enabled`. + + it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_API_ENABLED: "true", + }, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeDefined(); + }); + + it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_API_ENABLED: "false" }, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STUDIO_ENABLED: "true", + }, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { + // Reproduces the exact scenario a Go-started stack can hit: TOML says + // auth is disabled, but the running stack was actually started with + // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and + // status must still print its credentials. + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_AUTH_ENABLED: "true", + }, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_AUTH_ENABLED: "false" }, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + + it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_LOCAL_SMTP_ENABLED: "true", + }, + ); + expect(values.MAILPIT_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_EDGE_RUNTIME_ENABLED: "true", + }, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + }); + + describe("--override-name remapping", () => { + it("remaps a field's output KEY while leaving the value unchanged", () => { + const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.API_URL).toBeUndefined(); + expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + }); + + it("remaps every field independently when multiple overrides are given", () => { + const overrides = new Map([ + ["api.url", "CUSTOM_API_URL"], + ["db.url", "CUSTOM_DB_URL"], + ]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_API_URL).toBeDefined(); + expect(values.CUSTOM_DB_URL).toBeDefined(); + expect(values.API_URL).toBeUndefined(); + expect(values.DB_URL).toBeUndefined(); + }); + + it("leaves unrelated fields at their default name when only one is overridden", () => { + const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.REST_URL).toBeDefined(); + }); + + it("remaps the deprecated auth.jwt_secret/anon_key/service_role_key keys", () => { + const overrides = new Map([ + ["auth.jwt_secret", "CUSTOM_JWT_SECRET"], + ["auth.anon_key", "CUSTOM_ANON_KEY"], + ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], + ]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_JWT_SECRET).toBeDefined(); + expect(values.CUSTOM_ANON_KEY).toBeDefined(); + expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { + const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); + expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + }); + + it("combines stopped-service exclusions with --exclude flag exclusions", () => { + // Both `stopped` (from the health-check diff) and `--exclude` (user flag) + // funnel into the same `excluded` array in the handler; the pure function + // only sees the merged list. + const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + excluded, + NO_OVERRIDES, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.STUDIO_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + }); +}); + +describe("legacyShortContainerImageName", () => { + it("extracts the repo name between the first slash and the last colon", () => { + expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); + expect(legacyShortContainerImageName("library/kong:2.8.1")).toBe("kong"); + }); + + it("falls back to the full string when there is no slash/tag to extract", () => { + expect(legacyShortContainerImageName("kong")).toBe("kong"); + }); +}); + +describe("legacyStatusContainerIds", () => { + it("derives every named field from legacyServiceContainerIds's fixed array order", () => { + const ids = legacyStatusContainerIds("demo"); + expect(ids).toEqual({ + kong: "supabase_kong_demo", + auth: "supabase_auth_demo", + inbucket: "supabase_inbucket_demo", + rest: "supabase_rest_demo", + storage: "supabase_storage_demo", + studio: "supabase_studio_demo", + edgeRuntime: "supabase_edge_runtime_demo", + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 09cf725258..31de3f8e06 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase stop` +Native TypeScript port of Go's `internal/stop`. Talks directly to Docker via subprocess +(`docker`/`podman`), replicating Go's label-filtering and container-naming scheme +byte-for-byte — it does not go through `@supabase/stack/effect`'s orchestration model +(see the CLI-1324 plan's "Critical architectural finding" for why). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| -------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -18,37 +23,109 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither `stop` nor its Go counterpart make any Management API call. Everything is local +Docker + local `config.toml`. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id on the default path (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | resolves `LegacyCliConfig.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml`) | + +`docker`/`podman` must be resolvable on `PATH` (or reachable via the configured Docker +context) — `spawnContainerCli` tries `docker` first and falls back to `podman`. When +neither can be spawned at all, the error message names the actual root cause (e.g. +"docker: command not found (podman also not found) — install Docker Desktop or Podman +and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers stopped | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`LegacyStopMutuallyExclusiveError`) | +| `1` | `config.toml` present but malformed (`LegacyStopConfigLoadError`) — an **absent** file is not an error | +| `1` | listing containers failed (`LegacyStopListError`) | +| `1` | stopping one or more containers failed (`LegacyStopContainerError`) | +| `1` | `docker container prune` failed (`LegacyStopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`LegacyStopVolumePruneError`) | +| `1` | `docker network prune` failed (`LegacyStopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/stop/`. Go does not fire any custom telemetry event for +this command. ## Output +Go's `stop.RunE` never reads `-o`/`--output` itself, but the flag is still registered +on the root command as a `PersistentFlags()` enum (`cmd/root.go:330`, +`env|pretty|json|toml|yaml`) that every subcommand inherits, so `stop -o csv`/`-o table` +is rejected by pflag at parse time, before `RunE` runs — Go never reaches this command's +body with an unsupported value. `stop.command.ts` matches this: it wraps the handler +with `withLegacyCommandInstrumentation`, whose default `outputFormats` +(`LEGACY_RESOURCE_OUTPUT_FORMATS`, same `env|pretty|json|toml|yaml` set) validates and +rejects the flag before the handler runs — not a divergence, just enforced one layer up +rather than read inside this handler. Only the TS-native `--output-format` is consulted +by this handler's own logic below. + ### `--output-format text` (Go CLI compatible) -Prints "Stopped supabase local development setup." on success. +- stdout: `Stopping containers...` (printed unconditionally before any Docker call, + matching Go's `fmt.Fprintln` — see `docker.go:97`) +- stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan + when the output stream is a TTY, plain otherwise) +- stderr (conditional): when any Docker volume still carries the project's + `com.supabase.cli.project` label after stopping, an additional suggestion line: + - with a project id filter: `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project=` + - with `--all` (empty filter): `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project` ### `--output-format json` -Not applicable — stop is a local-dev workflow command. +Additive — no Go CLI equivalent. Single JSON object via `Output.success`: + +```json +{ "project_id_filter": "demo", "backup": true } +``` ### `--output-format stream-json` -Not applicable — stop is a local-dev workflow command. +Same payload as `json`, delivered as a `result` NDJSON event. ## Notes -- `--no-backup` deletes all data volumes after stopping. -- `--project-id` targets a specific local project ID to stop. -- `--all` stops all local Supabase instances across all projects on the machine. -- `--project-id` and `--all` are mutually exclusive. -- The hidden `--backup` flag (default true) is the inverse of `--no-backup`. +- `--project-id` and `--all` are **directory-independent** pure Docker-label filters — + neither reads `config.toml`. Only the no-flags default path resolves the project id + from `LegacyCliConfig.workdir` (env → config.toml `project_id` → workdir basename). +- The hidden `--backup` flag exists only for Go CLI surface parity — it has **no effect**. + Go declares it via `flags.Bool("backup", true, ...)` (`cmd/stop.go:26`) but never binds + the return value to a variable, so `RunE` always passes `!noBackup` to `stop.Run` + regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = +flags.noBackup`. `--backup=false` alone does **not** delete volumes; only + `--no-backup` does. +- Volume prune gates `--all` on the Docker daemon's API version (`legacy-container-cli.ts`'s + `legacyDockerSupportsVolumePruneAllFlag`, checked via `docker version --format +'{{.Server.APIVersion}}'`), matching Go's `Docker.ClientVersion() >= "1.42"` check + (`docker.go:126-133`) exactly. This isn't cosmetic: Docker CLI's own `--all` flag on + `volume prune` is annotated `version: "1.42"` and enforced by Cobra's `Args` validator + _before_ pruning runs, so sending it unconditionally on a pre-1.42 daemon hard-fails the + whole call instead of just pruning a narrower set. On the Podman fallback, `--all` is + omitted unconditionally instead: no released Podman `volume prune` (checked v4.3 through + the current v5.7) accepts that flag, and Podman already prunes every unused volume by + default, so dropping it there is lossless. Podman itself is a TS-only fallback (Go never + shells out to a `docker`/`podman` binary), so this has no Go-parity implication either way. +- Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), + mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before + failing the command (rather than stopping at the first failure), matching Go's + `errors.Join` over the full result set — though the surfaced message is a single fixed + string rather than a joined list of per-container errors, since Docker CLI subprocess + stderr isn't captured per-container the way Go's SDK error is. +- No e2e test is planned: there is no Docker-daemon-free golden path for this command, + and the e2e harness (`runSupabase()`) does not provision a real local stack. See the + CLI-1324 plan's "E2e tests" section for the full justification. diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index c89e0c3d1d..64fb443c6b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,5 +1,13 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -17,15 +25,41 @@ const config = { noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), ), + // Modelled as `Option` (presence = pflag `Changed`), not a plain + // boolean: Cobra's `MarkFlagsMutuallyExclusive("project-id", "all")` + // (`apps/cli-go/cmd/stop.go:31`) rejects the command whenever BOTH flags + // were explicitly set, regardless of the value `--all` was set to — the + // vendored cobra@v1.10.2 `flag_groups.go:139` check is + // `groupStatus[group][name] = flag.Changed`, not the flag's boolean value. + // A plain `Flag.boolean` here would make `--project-id x --all=false` + // indistinguishable from `--project-id x` (no `--all` at all), silently + // accepting a combination Go rejects. all: Flag.boolean("all").pipe( Flag.withDescription("Stop all local Supabase instances from all projects across the machine."), + Flag.optional, ), } as const; export type LegacyStopFlags = CliCommand.Command.Config.Infer; +// `stop` makes no Management API calls (Go's stop needs no access token) and talks +// directly to Docker, so it deliberately avoids `legacyManagementApiRuntimeLayer` — +// it provides only the services the handler + instrumentation consume. +// `ChildProcessSpawner` is not listed here: it comes from `BunServices` in the root +// runtime (`shared/cli/run.ts`), the same way `gen types`/`unlink` rely on it. +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStopRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["stop"]), +); + export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), - Command.withHandler((flags) => legacyStop(flags)), + Command.withHandler((flags) => + legacyStop(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyStopRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts new file mode 100644 index 0000000000..ac8d49db97 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -0,0 +1,62 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `stop`'s own flag validation or + * `RunE`, so a bad explicit workdir must fail here first, before config load + * or any Docker access. + */ +export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ + readonly message: string; +}> {} + +/** + * `--project-id` and `--all` were both set. Best-effort match of cobra's + * `MarkFlagsMutuallyExclusive` message shape (`stopCmd.MarkFlagsMutuallyExclusive("project-id", + * "all")`, `apps/cli-go/cmd/stop.go`). Cobra isn't vendored in this repo, so the exact + * wording could not be verified against source; this mirrors the same phrasing already + * used for `gen types`'s mutually-exclusive flag groups (`types.handler.ts`). + */ +export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( + "LegacyStopMutuallyExclusiveError", +)<{ + readonly message: string; +}> {} + +/** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ +export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ + readonly message: string; +}> {} + +/** + * Listing containers to stop failed. `stop`-specific wrapper over + * `LegacyDockerLifecycleListError` (see `legacy-docker-lifecycle.ts`) so this command's + * errors are all in one file with a `LegacyStop*` tag, matching the plan's error list. + */ +export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ + readonly message: string; +}> {} + +/** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ +export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ + readonly message: string; +}> {} + +/** `docker container prune` failed. */ +export class LegacyStopContainerPruneError extends Data.TaggedError( + "LegacyStopContainerPruneError", +)<{ + readonly message: string; +}> {} + +/** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ +export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ + readonly message: string; +}> {} + +/** `docker network prune` failed. */ +export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ab7f5f9ad8..3e31196de6 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,15 +1,410 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Result, Schema } from "effect"; + +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, +} from "../../shared/legacy-container-cli.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveLocalConfigValues } from "../../shared/legacy-local-config-values.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; +import { + LegacyStopConfigLoadError, + LegacyStopContainerError, + LegacyStopContainerPruneError, + LegacyStopListError, + LegacyStopMutuallyExclusiveError, + LegacyStopNetworkPruneError, + LegacyStopVolumePruneError, + LegacyStopWorkdirError, +} from "./stop.errors.ts"; + +/** + * Resolve the Docker label filter `stop` searches on. Go's flag precedence + * (`stop.go:14-22`): `--all` bypasses config entirely with an empty filter; + * `--project-id` overrides `Config.ProjectId` directly, also bypassing + * config.toml; otherwise `flags.LoadConfig` reads config.toml and + * `Config.ProjectId` (env → toml → workdir basename) is used. + * + * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell + * environment: `Config.Load` loads `supabase/.env`/`.env.local` *and* + * project-root/`SUPABASE_ENV`-selected dotenv files into the process env via + * `godotenv.Load` (`pkg/config/config.go:735-738,1169-1207`; godotenv never + * overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value + * overrides config.toml too, not only an ambient shell export. + * `legacyResolveProjectEnvironmentValues` implements that full precedence + * chain (see its doc comment) on top of `loadProjectEnvironment`'s + * `supabase/`-dir-only result, so it's used here instead of reading + * `process.env` directly. It still returns a usable map (falling back to + * `/supabase`/`workdir` and `process.env` itself) even when no + * `supabase/` config file exists at `workdir`, matching Go's `loadNestedEnv` + * running unconditionally before `config.toml` is ever opened + * (`pkg/config/config.go:786-793`) — the `?? process.env[...]` fallback below + * only still matters for keys neither source produced. + * + * The config/env-derived (default) branch is sanitized with + * {@link legacySanitizeProjectId} before it's used as a filter value, + * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` + * singleton once at config-load time (`pkg/config/config.go:938-944`) — every + * later reader, including the Docker LABEL `start` writes + * (`internal/utils/docker.go:375`), sees that same sanitized string. The + * explicit `--project-id` bypass stays RAW to match: Go assigns the flag + * value straight to `Config.ProjectId` without going through `Validate` + * (`internal/stop/stop.go:19-20`). + * + * Go's check is `len(projectId) > 0` (`internal/stop/stop.go:18`), not merely + * "was the flag set" — an explicit but empty `--project-id ""` falls through + * to the config.toml branch exactly like an absent flag, so that's mirrored + * here with a non-empty check rather than `Option.isSome` alone. + */ +const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( + function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { + // `internal/stop/stop.go:17`'s `if !all` reads the resolved value (not + // presence), so this branch stays value-based — `Option.getOrElse` mirrors + // Cobra's `BoolVar` default of `false` when `--all` was never passed. + if (Option.getOrElse(flags.all, () => false)) return ""; + if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { + return flags.projectId.value; + } + + // `search: false`: `cliConfig.workdir` already IS Go's fully-resolved chdir + // target (`legacy-cli-config.layer.ts`'s `resolveWorkdir` mirrors + // `ChangeWorkDir`'s explicit-exact-vs-default-searched resolution, + // `apps/cli-go/internal/utils/misc.go:231-247`), so letting + // `@supabase/config`'s `findProjectPaths` climb ancestors again on top of + // that would let an unrelated ancestor project's config.toml win when + // `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + + // Resolved BEFORE `loadProjectConfig` decodes config.toml (not after): + // Go's `Config.Load` runs `loadNestedEnv` before `LoadEnvHook` decodes + // `env(...)` references (`config.go:735-738`), so an `env(...)`-valued + // `project_id` sourced only from a project-root/`SUPABASE_ENV`-selected + // file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID` override read below. A malformed extra dotenv + // file throws here (see `readDotEnvFile`), matching Go's `loadNestedEnv` + // propagating `godotenv`'s parse error instead of silently skipping the + // bad line. `workdir` is passed through so dotenv files under + // `/supabase`/`workdir` are still discovered even when + // `projectEnv` is `null` (no config.toml there) — Go's own `loadNestedEnv` + // runs unconditionally, before `config.toml` is ever opened + // (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + catch: (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still + // resolves a project id via the workdir basename default. Only a + // malformed file (`loadProjectConfig` failing rather than returning + // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, potentially stopping containers for the wrong project. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // VALIDATE config before any Docker call, matching Go's `flags.LoadConfig` + // (config load + `Validate`, `internal/utils/flags/config_path.go:10-14` -> + // `pkg/config/config.go:882`), which the default `stop` path runs in full + // (`internal/stop/stop.go:15-25`) before ever touching Docker — unlike the + // `--all`/`--project-id` branches above, which bypass config loading + // entirely and so must NOT run this. `legacyResolveLocalConfigValues` is + // reused purely for its throwing side effects (its resolved URLs/keys are + // discarded); it gives `stop` the same partial-but-growing `Config.Validate` + // parity `status` already has (`status.handler.ts`), rather than a one-off + // re-implementation. `legacyGetHostname` has no Docker dependency, so it's + // safe to call speculatively here too. + yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + legacyGetHostname(), + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const resolved = legacyResolveLocalProjectId( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ); + return legacySanitizeProjectId(resolved); + }, +); export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["stop"]; - if (Option.isSome(flags.projectId)) args.push("--project-id", flags.projectId.value); - // `--backup` defaults to true; only forward when explicitly disabled, which - // matches the Go CLI semantics (`!noBackup` && `--backup=false`). - if (!flags.backup) args.push("--backup=false"); - if (flags.noBackup) args.push("--no-backup"); - if (flags.all) args.push("--all"); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before any of `stop`'s + // own flag validation or `RunE`. A missing or non-directory path fails + // immediately, so this must win over every later error, including the + // `--project-id`/`--all` mutual-exclusivity check below. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStopWorkdirError({ message: error.message })), + ); + + // Presence-based, matching Cobra's `Changed` check (see the doc comment on + // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` + // must reject too, not just `--all`/`--all=true`. + if (Option.isSome(flags.projectId) && Option.isSome(flags.all)) { + return yield* Effect.fail( + new LegacyStopMutuallyExclusiveError({ + // Cobra's `validateExclusiveFlagGroups` (spf13/cobra flag_groups.go): + // the group name keeps declaration order (`strings.Join(flagNames, " ")`), + // but the "were all set" list is `sort.Strings`-ed — verified against + // the vendored cobra@v1.10.2 source, not guessed. + message: + "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", + }), + ); + } + + const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliConfig); + // Go's hidden `--backup` flag is declared via `flags.Bool("backup", true, ...)` + // (`cmd/stop.go:26`) but its return value is discarded — never bound to a + // variable, so `RunE` always passes `!noBackup` to `stop.Run` regardless of + // `--backup`'s value. `--backup=false` is a no-op in the real Go binary + // today; only `--no-backup` deletes volumes. Matching that exactly (not the + // seemingly-intended-but-dead semantics of the flag's own description). + const deleteVolumes = flags.noBackup; + const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); + + // Go prints this line unconditionally and immediately — `docker.go:97`'s + // `fmt.Fprintln(w, "Stopping containers...")`, where `w` is a + // `StatusWriter` that `fmt.Println`s straight to stdout in non-interactive + // mode (`tea.go:59-60,87-90`) before any Docker call runs. The debounced + // `output.task` spinner used elsewhere in this codebase gates its message + // behind a delay, which drops this line whenever the underlying calls + // resolve faster than that threshold — exactly what happens against the + // mocked/replayed Docker CLI. Print it directly so it always appears. + if (output.format === "text") { + yield* output.raw("Stopping containers...\n"); + } + + yield* Effect.gen(function* () { + const containerIds = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: true, + format: "id", + }).pipe(Effect.mapError((cause) => new LegacyStopListError({ message: cause.message }))); + + // Go stops containers concurrently via `WaitAll`, joining every failure + // rather than short-circuiting on the first one (`docker.go:96-146`). + // + // `stdout`/`stderr: "ignore"` on every exit-code-only call below: none of + // these read the child's own output, and the default `"pipe"` stdio + // otherwise leaves an OS pipe unread — once `docker`/`podman` write + // enough to it (e.g. `container prune`'s "Deleted Containers" ID list on + // a host with many stale containers, most likely under `stop --all`), + // the child blocks on write() and `stop` hangs. Matches the existing + // `stdio: "ignore"` precedent for the same "exit-code-only" shape in + // `legacy-pgdelta.seam.layer.ts`. + const stopResults = yield* Effect.all( + containerIds.map((id) => + containerCliExitCode(spawner, ["stop", id], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.result), + ), + { concurrency: "unbounded" }, + ); + const failedStop = stopResults.find( + (result) => Result.isFailure(result) || result.success !== 0, + ); + if (failedStop !== undefined) { + return yield* Effect.fail( + new LegacyStopContainerError({ + message: `failed to stop container: ${ + Result.isFailure(failedStop) + ? legacyDescribeContainerCliFailure(failedStop.failure) + : `exit ${failedStop.success}` + }`, + }), + ); + } + + const containerPruneExitCode = yield* containerCliExitCode( + spawner, + ["container", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopContainerPruneError({ + message: `failed to prune containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (containerPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopContainerPruneError({ message: "failed to prune containers" }), + ); + } + + if (deleteVolumes) { + // Go gates the `--all` filter arg on Docker API >= 1.42 + // (`docker.go:126-133`, `Docker.ClientVersion() >= "1.42"`): Docker + // CLI's own `volume prune --all` flag is annotated `version: "1.42"` + // (`docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and enforced + // by Cobra's `Args` validator *before* `RunE` runs + // (`cmd/docker/docker.go:659-660`) — on an older daemon, passing + // `--all` unconditionally would hard-fail this whole call and prune + // nothing, not just prune a narrower set. There's no persistent + // Engine API client here to ask the negotiated version directly (Go + // talks to the Docker Engine API, never a `docker` binary), so + // {@link legacyDockerSupportsVolumePruneAllFlag} asks the `docker` CLI + // itself via `docker version` and mirrors Go's gate exactly. + // + // Podman is a Docker-CLI-compatible fallback this port adds, not something + // Go itself has, so there's no Go behavior to match on that path — but + // `--all` isn't a real flag on any released Podman `volume prune` (only + // `--filter`/`--force`/`--help`, checked v4.3 through the current v5.7; + // `--all` only exists in unreleased dev docs), so it hard-fails on a real + // Podman-only host. Podman already prunes every unused volume by default, + // so omitting `--all` on the Podman fallback is a lossless fix. + const dockerSupportsAll = yield* legacyDockerSupportsVolumePruneAllFlag(spawner); + const volumePruneExitCode = yield* containerCliExitCode( + spawner, + [ + "volume", + "prune", + "--force", + ...(dockerSupportsAll ? ["--all"] : []), + "--filter", + `label=${filterValue}`, + ], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ["volume", "prune", "--force", "--filter", `label=${filterValue}`], + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopVolumePruneError({ + message: `failed to prune volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (volumePruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopVolumePruneError({ message: "failed to prune volumes" }), + ); + } + } + + const networkPruneExitCode = yield* containerCliExitCode( + spawner, + ["network", "prune", "--force", "--filter", `label=${filterValue}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStopNetworkPruneError({ + message: `failed to prune networks: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), + ); + } + }); + + if (output.format === "text") { + // Written to stdout (no stream arg): `legacyAqua` must target stdout's own + // TTY status, not stderr's — see `legacy-colors.ts`'s doc comment. + yield* output.raw( + `Stopped ${legacyAqua("supabase", process.stdout)} local development setup.\n`, + ); + } else { + yield* output.success("Stopped supabase local development setup.", { + project_id_filter: searchProjectIdFilter, + backup: !deleteVolumes, + }); + } + + // Post-run suggestion (stop.go:26-37): only meaningful in text mode — json/ + // stream-json payloads have no equivalent field to carry this hint. + if (output.format === "text") { + const remainingVolumes = yield* legacyListVolumesByLabel(spawner, filterValue).pipe( + Effect.orElseSucceed(() => []), + ); + if (remainingVolumes.length > 0) { + const listVolumeCommand = + searchProjectIdFilter.length > 0 + ? `docker volume ls --filter label=com.supabase.cli.project=${searchProjectIdFilter}` + : "docker volume ls --filter label=com.supabase.cli.project"; + yield* output.raw( + `Local data are backed up to docker volume. Use docker to show them: ${legacyAqua(listVolumeCommand)}\n`, + "stderr", + ); + } + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index c9c9d83fea..3ac32c623c 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,55 +1,1122 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { vi } from "vitest"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyStop } from "./stop.handler.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; -function setupLegacyStop() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); +const tempRoot = useLegacyTempWorkdir("supabase-stop-int-"); + +function flags(overrides: Partial = {}): LegacyStopFlags { + return { + projectId: Option.none(), + backup: true, + noBackup: false, + all: Option.none(), + ...overrides, + }; +} + +function writeConfig(workdir: string, projectId: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); +} + +function writeEnvFile(workdir: string, fileName: ".env" | ".env.local", contents: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, fileName), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** + * Routes each spawned invocation to a caller-supplied result by matching argv + * (rather than a fixed call sequence): `stop` issues five distinct docker + * subcommands (`ps`, `stop`, `container prune`, `volume prune`, `network prune`, + * `volume ls`) whose relative order/count varies per scenario (N `stop` calls for + * N listed containers), so a routing table is a better fit than the sequential + * step-array mock `gen types` uses for its single linear pipeline. + */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + // Fails BOTH docker and podman spawn attempts for argv matching this predicate, + // simulating a runtime that cannot be spawned at all (as opposed to a spawned + // process exiting non-zero) — exercises the `Effect.mapError`/`orElseSucceed` + // spawn-failure branches distinct from the exit-code-checking branches. + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +/** + * Default happy-path router: `ps` lists one container, `docker version` reports + * an API version comfortably at/above the `volume prune --all` gate (1.42, see + * `legacyDockerSupportsVolumePruneAllFlag`), everything else succeeds empty. + */ +function defaultRoute( + opts: { + readonly containerIds?: ReadonlyArray; + readonly volumeNames?: ReadonlyArray; + readonly dockerApiVersion?: string; + } = {}, +) { + const containerIds = opts.containerIds ?? ["c1"]; + const volumeNames = opts.volumeNames ?? []; + const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "ps") return { stdout: containerIds }; + if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + if (args[0] === "version") return { stdout: [dockerApiVersion] }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly configuredProjectId?: string; + readonly skipConfig?: boolean; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.workdir ?? tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configuredProjectId ?? "demo"); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStopFlags = { - projectId: Option.none(), - backup: true, - noBackup: false, - all: false, -}; +describe("legacy stop integration", () => { + it.live( + "stops the current project's containers with backup and suggests the volume command", + () => { + const { layer, out, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ containerIds: ["c1", "c2"], volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + const stopCalls = child.spawned.filter((s) => s.args[0] === "stop"); + expect(stopCalls.map((s) => s.args)).toEqual([ + ["stop", "c1"], + ["stop", "c2"], + ]); + expect(out.stdoutText).toContain("Stopping containers..."); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stdoutText).toContain("local development setup."); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain( + "docker volume ls --filter label=com.supabase.cli.project=demo", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering on the raw value here would match nothing `start` + // ever labeled. + const { layer, child } = setup({ + configuredProjectId: "My App!!", + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=My_App_", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => { + // Go assigns the --project-id flag value straight to Config.ProjectId + // without going through Validate (internal/stop/stop.go:19-20), so this + // path must NOT sanitize even though the default (config-derived) path does. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("Raw Value!!") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=Raw Value!!", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("stops every project's containers with --all without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ all: Option.some(true) })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.ID}}", + ]); + const pruneCalls = child.spawned.filter( + (s) => s.args[0] === "container" && s.args[1] === "prune", + ); + expect(pruneCalls[0]?.args).toEqual([ + "container", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("suggests the bare-label volume command with --all when volumes remain", () => { + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ all: Option.some(true) })); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain("docker volume ls --filter label=com.supabase.cli.project"); + expect(out.stderrText).not.toContain("com.supabase.cli.project="); + }).pipe(Effect.provide(layer)); + }); + + it.live("stops a named project with --project-id without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("other-project") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=other-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to config.toml when --project-id is an empty string", () => { + // Go's check is `len(projectId) > 0` (internal/stop/stop.go:18), not just + // "was --project-id set" — an empty value must fall through to config.toml + // exactly like an absent flag, not resolve to the bare/all-projects filter. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=env-file-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=ambient-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + + it.live( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, "ancestor-project"); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=no-config-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=root-env-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before any of + // `stop`'s own flag validation, config load, or Docker access — a missing + // path must fail immediately, not fall through to the workdir-basename + // default and prune under that name. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-id together with --all", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + // Cobra's `MarkFlagsMutuallyExclusive` mutex is presence-based (`Changed`), + // not value-based — `--all=false` still counts as "set" alongside + // `--project-id`, so this must reject too, not just `--all`/`--all=true`. + it.live("rejects --project-id together with an explicit --all=false", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: Option.some(false) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("deletes data volumes with --no-backup", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", + () => { + // Docker CLI's own `volume prune --all` flag requires API >= 1.42 and + // hard-fails (pruning nothing) on an older daemon — Go avoids ever + // sending it by checking `Docker.ClientVersion() >= "1.42"` + // (docker.go:126-133). This mirrors that gate via `docker version`. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.41" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("includes --all in docker's volume prune when the API is exactly 1.42", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.42" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { + // Go's `--backup` is declared but never bound to a variable (`cmd/stop.go:26`) — + // `RunE` always passes `!noBackup`, so `--backup=false` has zero effect in the + // real Go binary today. Only `--no-backup` deletes volumes. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: false })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--no-backup still deletes data volumes even when --backup stays true", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: true, noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps data volumes by default (no volume prune call)", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); -describe("legacy stop", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's Config.Validate builds the duplicate map across all [remotes.*] + // blocks unconditionally (config.go:503-518), so this must fail before + // stop ever selects a remote or touches Docker — not just when a + // matching --project-ref is requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "aaaaaaaaaaaaaaaaaaaa" + +[remotes.b] +project_id = "aaaaaaaaaaaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(baseFlags); - expect(calls).toEqual([["stop"]]); + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --backup=false when the hidden --backup flag is disabled", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load, so an invalid + // format must fail closed before stop reaches Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop({ ...baseFlags, backup: false }); - expect(calls).toEqual([["stop", "--backup=false"]]); + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --no-backup, --project-id and --all", () => { - const { layer, calls } = setupLegacyStop(); + it.live( + "decodes a comma-separated string into an array field ([]string) for stop to proceed", + () => { + // Go's `newDecodeHook` wires `mapstructure.StringToSliceHookFunc(",")` + // unconditionally, so a plain string value for a `[]string` field like + // `additional_redirect_urls` decodes fine and must not block stop from + // reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits + // this WARN via `Console.error` only when `goViperCompat` is set — verify + // legacy stop keeps that Go-parity behavior wired on. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth.external.slack] +enabled = true +`, + ); + const { layer } = setup({ skipConfig: true, route: defaultRoute() }); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); return Effect.gen(function* () { - yield* legacyStop({ - projectId: Option.some("abc"), - backup: true, - noBackup: true, - all: true, + yield* legacyStop(flags()); + expect(warnings.some((m) => m.includes('WARN: disabling deprecated "slack" provider'))).toBe( + true, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live( + "fails and never spawns docker when config.toml has an unsupported db.major_version", + () => { + // Matches Go's default `stop` path, which runs `flags.LoadConfig` (config + // load + `Validate`) entirely before any Docker call + // (`internal/stop/stop.go:15-25` -> `pkg/config/config.go:882`) — a config + // Go rejects must fail `stop` before it touches containers, not just when + // reading `project_id`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("does not run config Validate for --all (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: the `--all` branch never calls + // `flags.LoadConfig`, so an otherwise-invalid config.toml must not block it. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ all: Option.some(true) }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not run config Validate for --project-id (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: an explicit `--project-id` sets + // `Config.ProjectId` directly and never calls `flags.LoadConfig`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ projectId: Option.some("explicit") }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=explicit"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when stopping a container errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a container cannot be spawned to stop it at all", () => { + // Distinct from a spawned `docker stop` exiting non-zero (covered above) — + // this exercises the branch where docker AND podman both fail to spawn for + // the `stop ` argv specifically. + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { stdout: ["c1"] } : { exitCode: 0 }), + failSpawnFor: (args) => args[0] === "stop", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "fails the same way in json mode, where 'Stopping containers...' is never printed", + () => { + // The `output.format === "text"` gate around the "Stopping containers..." + // line means json mode skips it entirely; this exercises that the + // list/stop/prune failure path is unaffected by that gate. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, }); - expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when container prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "container" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "volume" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "network" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the container list errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the + // successful call is the LAST matching record, not the first. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits --all from podman's volume prune (not a real Podman flag)", () => { + // No released Podman `volume prune` accepts `--all` (only `--filter`/`--force`/ + // `--help`), so passing Docker's `--all` argv straight through to the Podman + // fallback would hard-fail after containers are already stopped. Podman prunes + // every unused volume by default, so dropping `--all` there is lossless. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePruneCalls = child.spawned.filter( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePruneCalls.at(-1)?.command).toBe("podman"); + expect(volumePruneCalls.at(-1)?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result in json mode without spinner text", () => { + const { layer, out } = setup({ + format: "json", + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_id_filter: "demo", backup: true }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + // json mode has no volume-suggestion equivalent — only text mode emits it. + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("shows no volume suggestion when no volumes remain", () => { + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: [] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { exitCode: 1 } : { exitCode: 0 }), + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStop(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "container" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "network" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("still reports success when the post-run volume listing fails", () => { + // The volume-suggestion check is best-effort (`Effect.orElseSucceed`): a + // failure listing volumes after a successful stop must not fail the command, + // matching Go's `if resp, err := ...; err == nil && ...` (stop.go:29) — a + // listing error there is silently ignored, not surfaced. + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "ls", + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stderrText).not.toContain("Local data are backed up"); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts new file mode 100644 index 0000000000..95452c78c7 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.live.test.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` (still a Go-proxy) creates. `describeLive` is reused +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "Live tests" section for the full convention. +describeLive("supabase stop (live)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); + // No `project_id` override, so the cli resolves it from the workdir + // basename — matching Go's precedence exactly (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + + const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ]); + expect(remaining.trim()).toBe(""); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md index 92ad3b6ccd..5bf3726586 100644 --- a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md @@ -7,12 +7,13 @@ when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | ----------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| Path | Format | When | +| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked) | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | +| local Kong TLS cert/key | PEM | local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -36,7 +37,9 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Environment Variables `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm). +`SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm) — +read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files +(shell wins; CLI-1878, matching Go's `loadNestedEnv` before `viper.GetBool("YES")`). `storage` is an experimental command (Go `root.go:63`): `rm` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts index dda8bc12fe..aa361c58e9 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts @@ -1,12 +1,13 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LEGACY_DELETE_OBJECTS_LIMIT, @@ -54,14 +55,27 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const resolver = yield* LegacyProjectRefResolver; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; let linkedRef = ""; yield* Effect.gen(function* () { + // Resolve the project ref BEFORE reading the project `.env`, matching Go's + // `ParseDatabaseConfig` `case linked:` (`db_url.go:87-93`), which calls + // `LoadProjectRef` strictly before `LoadConfig` (the `.env`/`loadNestedEnv` + // work). An unlinked workdir must fail fast with the not-linked guidance + // before a malformed/unreadable `supabase/.env` gets a chance to mask it + // with an env-parse error. const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); linkedRef = projectRef; + // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). Both the + // `--local` and (default) `--linked` branches of `ParseDatabaseConfig` call + // `LoadConfig` — which loads the project `.env` files — before `rm.Run`'s + // confirmation prompt (`root.go:118` → `db_url.go:78` (`local`) / `:91` (`linked`)), + // so a `SUPABASE_YES` set only in `supabase/.env` must auto-confirm here too. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const loaded = yield* legacyLoadStorageConfig(cliConfig.workdir, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index c279b49460..858ea9953a 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -100,6 +100,57 @@ describe("legacy storage rm", () => { }); }); + it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { + // SUPABASE_YES lives only in supabase/.env, not the shell — both the `--local` and + // (default) `--linked` branches of Go's `ParseDatabaseConfig` load the project `.env` + // files before `rm.Run`'s confirmation prompt (root.go:118), so the deletion + // auto-confirms with no --yes flag and no env var set in the shell (CLI-1878). + const { layer, out, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: true, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("[y/N] y"); + expect(requests.some((r) => r.method === "DELETE")).toBe(true); + }); + }); + + it.live( + "surfaces not-linked guidance before a malformed project .env (Go LoadProjectRef-before-LoadConfig)", + () => { + // Go's `ParseDatabaseConfig` `case linked:` (db_url.go:87-93) calls `LoadProjectRef` + // strictly before `LoadConfig` (which reads the project `.env` files), so an unlinked + // workdir must fail with the not-linked guidance even when `supabase/.env` is malformed + // — the malformed file must never be reached (CLI-1878). + const { layer, requests } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + linkedFails: true, + files: { "supabase/.env": "!=\n" }, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("Cannot find project ref"); + expect(JSON.stringify(exit)).not.toContain("failed to parse environment file"); + expect(requests).toHaveLength(0); + }); + }, + ); + it.live("skips the bucket when the confirmation is declined", () => { const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index f4d5fd15f9..dfad46cac2 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -50,8 +50,8 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( workdir: string, projectRef: string, ) { - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md index 1be0ffbf9f..cb0719af81 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md @@ -17,7 +17,8 @@ instead of `~/.supabase/telemetry.json`. ## API Routes -`disable` is fully local. No network calls are made. +None called directly. `cli_command_executed` may be sent to PostHog — see +Telemetry Events Fired below. ## Environment Variables @@ -34,8 +35,26 @@ instead of `~/.supabase/telemetry.json`. ## Telemetry Events Fired -None. The command disables analytics capture so toggling telemetry does not emit -`cli_command_executed`, matching Go. +| Event | When | +| ---------------------- | ------------------------------------------------------------------------- | +| `cli_command_executed` | when telemetry was **already enabled** before this invocation (see below) | + +Go parity (`apps/cli-go/cmd/root.go:131-138,171-181`): the event is gated on +the consent state read at process start, before this command's handler +rewrites `telemetry.json` — not on the value the command just wrote. Running +`disable` while telemetry is enabled fires the event one last time (using +the pre-toggle, still-enabled snapshot); running it while telemetry is +already disabled stays silent. See `telemetry/enable/SIDE_EFFECTS.md` for +the mirror-image case. + +When the event fires, the process waits (bounded, up to ~5s) for a PostHog +flush attempt to complete or time out before exiting, since the analytics +client's shutdown is a finalizer around the whole command run. Previously +`disable`/`enable` never queued an event and returned immediately; on a +slow or fully offline network, this invocation can now take noticeably +longer than before, though the `Telemetry is disabled.` stdout line is +still written before that wait (it comes from the handler, which completes +before the surrounding instrumentation's post-run capture/flush step). ## Output diff --git a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts index 4914b2c2a6..4618901625 100644 --- a/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/disable/disable.command.ts @@ -12,8 +12,17 @@ export const legacyTelemetryDisableCommand = Command.make("disable", config).pip Command.withDescription("Disable CLI telemetry."), Command.withShortDescription("Disable telemetry"), Command.withHandler((flags) => + // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires + // gated on the CONSENT SNAPSHOT TAKEN BEFORE this command's handler runs + // (Go's PersistentPreRunE reads the on-disk state before `disable`'s RunE + // mutates it), not on the just-written `false`. `legacyAnalyticsLayer` + // reproduces that naturally: it reads `TelemetryRuntime.consent` once, at + // layer-construction time, before the handler below ever executes, so + // leaving analytics enabled here fires the event exactly when telemetry + // was enabled prior to this invocation, and stays silent when it was + // already disabled — see `enable.command.ts` for the mirror-image case. legacyTelemetryDisable(flags).pipe( - withLegacyCommandInstrumentation({ analytics: false, flags }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md index 3942a107dc..d9b81200ab 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md @@ -17,7 +17,8 @@ instead of `~/.supabase/telemetry.json`. ## API Routes -`enable` is fully local. No network calls are made. +None called directly. `cli_command_executed` may be sent to PostHog — see +Telemetry Events Fired below. ## Environment Variables @@ -34,8 +35,27 @@ instead of `~/.supabase/telemetry.json`. ## Telemetry Events Fired -None. The command disables analytics capture so toggling telemetry does not emit -`cli_command_executed`, matching Go. +| Event | When | +| ---------------------- | ------------------------------------------------------------------------- | +| `cli_command_executed` | when telemetry was **already enabled** before this invocation (see below) | + +Go parity (`apps/cli-go/cmd/root.go:131-138,171-181`): the event is gated on +the consent state read at process start, before this command's handler +rewrites `telemetry.json` — not on the value the command just wrote. In the +common case (enabling from a disabled state) the pre-toggle snapshot is +`false`, so nothing fires; running `enable` while telemetry is already +enabled fires the event, matching Go's uniform, state-based (not +command-based) gate. See `telemetry/disable/SIDE_EFFECTS.md` for the +mirror-image case. + +When the event fires, the process waits (bounded, up to ~5s) for a PostHog +flush attempt to complete or time out before exiting, since the analytics +client's shutdown is a finalizer around the whole command run. Previously +`disable`/`enable` never queued an event and returned immediately; on a +slow or fully offline network, this invocation can now take noticeably +longer than before, though the `Telemetry is enabled.` stdout line is +still written before that wait (it comes from the handler, which completes +before the surrounding instrumentation's post-run capture/flush step). ## Output diff --git a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts index 5497f0e929..df79ad2505 100644 --- a/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/legacy/commands/telemetry/enable/enable.command.ts @@ -12,8 +12,14 @@ export const legacyTelemetryEnableCommand = Command.make("enable", config).pipe( Command.withDescription("Enable CLI telemetry."), Command.withShortDescription("Enable telemetry"), Command.withHandler((flags) => + // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires + // gated on the pre-toggle consent snapshot, same as `disable` — see that + // command's comment. In the common case (enabling from a disabled state) + // the snapshot is `false`, so the event stays silent; running `enable` + // when telemetry is ALREADY enabled fires it, matching Go's uniform, + // state-based (not command-based) gate. legacyTelemetryEnable(flags).pipe( - withLegacyCommandInstrumentation({ analytics: false, flags }), + withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts index 99af77c201..870403c99e 100644 --- a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts @@ -6,8 +6,18 @@ import path from "node:path"; import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; -import { mockAnalytics, mockOutput, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockProjectContext, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; +import { cliConfigLayer } from "../../../next/config/cli-config.layer.ts"; import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; +import { EventCommandExecuted } from "../../../shared/telemetry/event-catalog.ts"; +import { legacyAnalyticsLayer } from "../../telemetry/legacy-analytics.layer.ts"; import { legacyTelemetryCommand } from "./telemetry.command.ts"; function makeTempDir(): string { @@ -32,6 +42,42 @@ function setup(dir: string) { processControlLayer, processEnvLayer({ SUPABASE_HOME: dir }), ); + return { out, analytics, layer }; +} + +// Wires the REAL `legacyAnalyticsLayer` (consent-gated, backed by +// `telemetryRuntimeLayer` reading `dir`'s telemetry.json) instead of +// `mockAnalytics()` — the un-mocked boundary `Analytics.capture` calls +// actually pass through. No PostHog key is set in the test env, so +// `legacyAnalyticsLayer` resolves to its no-op branch regardless of consent +// (real-network PostHog delivery has no test double anywhere in this repo); +// this proves the command runs the real consent-gated layer end-to-end +// without crashing, not the exact PostHog call count. The snapshot-timing +// mechanism itself (pre-toggle consent surviving the handler's own disk +// write) is proven directly in `runtime.layer.unit.test.ts`. +function setupWithRealAnalytics(dir: string) { + const out = mockOutput(); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: dir }); + const ttyLayer = mockTty(); + const envLayer = processEnvLayer({ SUPABASE_HOME: dir }); + const projectContextLayer = mockProjectContext(); + const configLayer = cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + ); + const analyticsLayer = legacyAnalyticsLayer.pipe( + Layer.provide(configLayer), + Layer.provide(runtimeInfoLayer), + Layer.provide(ttyLayer), + Layer.provide(BunServices.layer), + ); + const layer = Layer.mergeAll( + out.layer, + analyticsLayer, + BunServices.layer, + processControlLayer, + envLayer, + ); return { out, layer }; } @@ -135,6 +181,91 @@ describe("legacy telemetry integration", () => { ) as Effect.Effect; }); + // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` is gated on + // the consent SNAPSHOT taken before the handler runs, not the value the handler + // just wrote. These two assert the narrower wiring fix using `mockAnalytics()` + // (which unconditionally records every capture, bypassing consent entirely): + // `disable`/`enable` no longer force-suppress analytics via `analytics: false`, + // so the shared instrumentation wrapper actually reaches `Analytics.capture`. + // The snapshot-timing mechanism itself — that the pre-toggle value survives the + // handler's own on-disk write — is proven directly against `telemetryRuntimeLayer` + // in `shared/telemetry/runtime.layer.unit.test.ts`. The two tests further below + // run the same commands through the REAL, consent-gated `legacyAnalyticsLayer` + // (not this mock) to prove the production wiring doesn't crash end-to-end. + it.live("disable no longer force-suppresses cli_command_executed", () => { + const dir = makeTempDir(); + const { analytics, layer } = setup(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); + expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("enable no longer force-suppresses cli_command_executed", () => { + const dir = makeTempDir(); + const { analytics, layer } = setup(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); + expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("disable runs cleanly through the real consent-gated analytics layer", () => { + const dir = makeTempDir(); + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + schema_version: 1, + }), + ); + const { out, layer } = setupWithRealAnalytics(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); + expect(out.stdoutText).toBe("Telemetry is disabled.\n"); + expect(readTelemetryConfig(dir).enabled).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + + it.live("enable runs cleanly through the real consent-gated analytics layer", () => { + const dir = makeTempDir(); + writeFileSync( + telemetryPath(dir), + JSON.stringify({ + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + schema_version: 1, + }), + ); + const { out, layer } = setupWithRealAnalytics(dir); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); + expect(out.stdoutText).toBe("Telemetry is enabled.\n"); + expect(readTelemetryConfig(dir).enabled).toBe(true); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ) as Effect.Effect; + }); + it.live( "status treats malformed typed fields as a corrupted file and regenerates identity", () => { diff --git a/apps/cli/src/legacy/commands/test/new/new.command.ts b/apps/cli/src/legacy/commands/test/new/new.command.ts index 047d0b16ab..cb423da1a9 100644 --- a/apps/cli/src/legacy/commands/test/new/new.command.ts +++ b/apps/cli/src/legacy/commands/test/new/new.command.ts @@ -39,7 +39,19 @@ export const legacyTestNewCommand = Command.make("new", config).pipe( Command.withDescription("Create a new test file."), Command.withShortDescription("Create a new test file"), Command.withHandler((flags) => - legacyTestNew(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + legacyTestNew(flags).pipe( + withLegacyCommandInstrumentation({ + flags, + config, + // `--template` registers `-t` (Flag.withAlias above); without this, + // `-t pgtap` never resolves to the canonical `template` name in + // extractChangedFlagNames, so it wouldn't appear in telemetry at all + // (Go's pflag.Visit reports the canonical name regardless of shorthand — + // cmd/root_analytics.go:53-76). + aliases: { t: "template" }, + }), + withJsonErrorHandling, + ), ), Command.provide(legacyTestNewRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 23fbe98051..aace0d1a0b 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index dc361d9fbc..9fc14225dc 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index 2596dc4cb6..5e67410233 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 15c79abde7..73b367ec63 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `PROJECT_ID` env are both unset | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | ## Files Written @@ -25,8 +25,8 @@ | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | ## Exit Codes diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 70ec567d30..843059bf59 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -2,7 +2,11 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; -import { legacyPoolerHost, legacyProjectHost } from "../shared/legacy-profile.ts"; +import { + legacyDashboardUrl, + legacyPoolerHost, + legacyProjectHost, +} from "../shared/legacy-profile.ts"; import { LegacyDebugLogger, type LegacyDebugLoggerShape, @@ -16,6 +20,7 @@ interface ResolvedProfile { readonly apiUrl: string; readonly projectHost: string; readonly poolerHost: string; + readonly dashboardUrl: string; } const BUILTIN_PROFILE_API_URLS: Record = { @@ -38,6 +43,7 @@ function resolvedBuiltin(name: LegacyProfileName): ResolvedProfile { apiUrl: BUILTIN_PROFILE_API_URLS[name], projectHost: legacyProjectHost(name), poolerHost: legacyPoolerHost(name), + dashboardUrl: legacyDashboardUrl(name), }; } @@ -47,6 +53,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; } | undefined { try { @@ -57,6 +64,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; }) : undefined; } catch { @@ -147,10 +155,33 @@ function resolveProfile( // that omits `pooler_host:` yields an empty host, which disables the MITM // domain assertion — it must NOT fall back to the production `supabase.com`. poolerHost: typeof parsed.pooler_host === "string" ? parsed.pooler_host : "", + // Go's `Profile.DashboardURL` is `required` (`profile.go:20`); a YAML profile + // that omits it falls back to the built-in `supabase` dashboard here rather + // than erroring, since it only feeds the connect-failure suggestion text. + dashboardUrl: + typeof parsed.dashboard_url === "string" + ? parsed.dashboard_url + : legacyDashboardUrl("supabase"), }; }); } +/** + * Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + * `os.Chdir(workdir)`s using the raw `--workdir`/`SUPABASE_WORKDIR` string, + * which can be relative (e.g. `.`) — but every later reader of the resolved + * workdir (including the `Config.ProjectId` cwd-basename default, `Eject`, + * `pkg/config/config.go:561-570`, run on every `Config.Load()` via + * `mergeDefaultValues`, `config.go:690-699`) reads `os.Getwd()`, the real + * ABSOLUTE directory, never the raw configured string. `os.Chdir(".")` is a + * no-op syscall-wise, so Go's `cwd` is unaffected by the flag/env value being + * relative. This resolves the flag/env value against the real process `cwd` + * the same way, so `LegacyCliConfig.workdir` is always absolute — matching + * Go's invariant that basename-ing it (e.g. `legacyResolveLocalProjectId`'s + * workdir-basename fallback) operates on a real directory name, not a + * relative-path fragment like `.` (which would sanitize to an empty project + * id and build a bare, all-projects-matching Docker label filter). + */ function resolveWorkdir( flagValue: Option.Option, envValue: string | undefined, @@ -160,10 +191,10 @@ function resolveWorkdir( ): Effect.Effect { return Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return flagValue.value; + return path.resolve(cwd, flagValue.value); } if (envValue !== undefined && envValue.length > 0) { - return envValue; + return path.resolve(cwd, envValue); } let current = cwd; // Walk up until we hit a directory containing supabase/config.toml or the FS root. @@ -200,6 +231,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, } = yield* resolveProfile( profileFlag, env["SUPABASE_PROFILE"], @@ -236,6 +268,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, accessToken, projectId, workdir, diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 541e2d4a9b..0bb042b435 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -56,6 +56,7 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("https://api.supabase.com"); expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); @@ -154,7 +155,7 @@ describe("legacyCliConfigLayer", () => { ), ); - it.effect("loads api_url, name, and pooler_host from a YAML profile file", () => { + it.effect("loads api_url, name, pooler_host, and dashboard_url from a YAML profile file", () => { const profilePath = join(tempRoot, "profile.yaml"); writeFileSync( profilePath, @@ -163,6 +164,7 @@ describe("legacyCliConfigLayer", () => { 'api_url: "http://127.0.0.1:9999"', "project_host: localhost", "pooler_host: staging.example.com", + 'dashboard_url: "http://127.0.0.1:9999"', ].join("\n"), ); return Effect.gen(function* () { @@ -171,6 +173,9 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("http://127.0.0.1:9999"); expect(config.projectHost).toBe("localhost"); expect(config.poolerHost).toBe("staging.example.com"); + // Go reads `dashboard_url` from the profile (used by the connect-failure hint); + // the cli-e2e harness points it at the replay server for parity. + expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); @@ -183,6 +188,8 @@ describe("legacyCliConfigLayer", () => { // Go's Profile.PoolerHost is `omitempty`: an absent pooler_host disables the // MITM domain assertion rather than falling back to supabase.com. expect(config.poolerHost).toBe(""); + // An omitted dashboard_url falls back to the built-in supabase dashboard. + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); @@ -271,6 +278,43 @@ describe("legacyCliConfigLayer", () => { ), ); + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + // `os.Chdir`s the raw flag/env value, but every later reader — including the + // `Config.ProjectId` cwd-basename default (`Eject`, `pkg/config/config.go: + // 561-570`) — reads `os.Getwd()`, the real absolute directory, never the raw + // string. A relative `--workdir .`/`SUPABASE_WORKDIR=.` must therefore resolve + // to an absolute path here too, not stay `"."` (which would later basename to + // an empty project id). + it.effect("resolves a relative --workdir flag against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + ); + + it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(join(tempRoot, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + ); + + it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + ); + + it.effect("keeps an absolute --workdir flag unchanged", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + ), + ); + it.effect("walks up from CWD looking for supabase/config.toml", () => { const projectRoot = join(tempRoot, "project"); const nested = join(projectRoot, "deep", "child"); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts index 3bef2e2a1c..432d3d267a 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.service.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -29,6 +29,14 @@ interface LegacyCliConfigShape { * db-config resolver's MITM domain check. */ readonly poolerHost: string; + /** + * Dashboard base URL for the active profile (Go's `Profile.DashboardURL`, + * `apps/cli-go/internal/utils/profile.go:20`). Sourced from the resolved profile — + * the built-in table for named profiles, or the `dashboard_url:` key of a YAML + * profile file — so staging/custom dashboards are honored. Used by the + * connect-failure suggestion (Go's `SetConnectSuggestion` network-restrictions hint). + */ + readonly dashboardUrl: string; readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 3990163113..04b64ff06b 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -24,6 +24,7 @@ function mockCliConfig(opts: { workdir: string; projectId?: string }) { apiUrl: "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), workdir: opts.workdir, diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts index 5c5e74f94b..f54579c331 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.service.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -41,9 +41,14 @@ interface LegacyProjectRefResolverShape { * `projects list` (`list.go:31-33`), which ignores `ErrNotLinked` and only * uses the value as a "linked" marker. Returns `None` when nothing resolves. * - * Unlike `resolve`, the returned value is **not** format-validated — Go's - * soft load also skips validation here, and the value is only used as a - * display marker, never injected into an API path. + * Unlike `resolve`, the returned value is **not** format-validated. Note this + * is a caller-side simplification, not a Go behavior match: Go's + * `LoadProjectRef` still validates and warns to stderr on a malformed ref + * (see `services`'s equivalent handling, CLI-1872), it just doesn't fail the + * command. `resolveOptional` skips the warning too, which is safe only + * because every current caller uses the value purely as a display marker, + * never injected into an API path — a caller that needs the Go-parity + * warning should validate and warn itself rather than assume this does it. */ readonly resolveOptional: ( flagValue: Option.Option, diff --git a/apps/cli/src/legacy/shared/legacy-api-url.ts b/apps/cli/src/legacy/shared/legacy-api-url.ts new file mode 100644 index 0000000000..c48b73ac44 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-api-url.ts @@ -0,0 +1,26 @@ +/** + * Local API URL derivation, mirroring Go's `config.go:634-644` + `misc.go:298`: + * an explicit `api.external_url` wins, otherwise `://:` + * where the scheme follows `api.tls.enabled` and the port is `api.port`. + * Hoisted here because `legacy-storage-credentials.ts` and + * `legacy-local-config-values.ts` both need this exact computation. + */ +export function legacyResolveApiExternalUrl( + config: { + readonly external_url?: string; + readonly port: number; + readonly tls: { readonly enabled: boolean }; + }, + hostname: string, +): string { + if (config.external_url !== undefined && config.external_url.length > 0) { + return config.external_url; + } + const scheme = config.tls.enabled ? "https" : "http"; + // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an + // IPv6 host. + const hostPort = hostname.includes(":") + ? `[${hostname}]:${config.port}` + : `${hostname}:${config.port}`; + return `${scheme}://${hostPort}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-colors.ts b/apps/cli/src/legacy/shared/legacy-colors.ts index c41cfae7d4..25d32d7c42 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.ts @@ -6,27 +6,38 @@ import { styleText } from "node:util"; * Go uses lipgloss, which auto-detects the output profile and renders **plain** * text when the stream is not a TTY (piped output, CI, tests). `styleText` * mirrors that: with `validateStream` (the default) it checks the target stream - * and `NO_COLOR`, returning the unstyled string when colour is unsupported. We - * point it at `process.stderr` because the bootstrap progress / suggestion lines - * these style are written to stderr. + * and `NO_COLOR`, returning the unstyled string when colour is unsupported. + * + * `stream` defaults to `process.stderr` because every original call site styles + * progress/suggestion lines written to stderr. A caller styling content that is + * itself written to **stdout** (e.g. `status`'s pretty table) must pass + * `process.stdout` explicitly — otherwise the TTY check runs against the wrong + * stream, and piping stdout while stderr stays a TTY (`supabase status | less`) + * would corrupt the piped output with ANSI escapes (the same bug class CLI-1546 + * fixed for the progress spinner). * * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. */ -export function legacyAqua(text: string): string { - return styleText("cyan", text, { stream: process.stderr }); +export function legacyAqua(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("cyan", text, { stream }); } -export function legacyBold(text: string): string { - return styleText("bold", text, { stream: process.stderr }); +export function legacyBold(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("bold", text, { stream }); } /** Port of Go's `utils.Yellow` — lipgloss colour "11" (bright yellow). */ -export function legacyYellow(text: string): string { - return styleText("yellow", text, { stream: process.stderr }); +export function legacyYellow(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("yellow", text, { stream }); } /** Port of Go's `utils.Red` — lipgloss colour "9" (bright red). */ -export function legacyRed(text: string): string { - return styleText("red", text, { stream: process.stderr }); +export function legacyRed(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("red", text, { stream }); +} + +/** Port of Go's `utils.Green` — lipgloss colour "10" (bright green). */ +export function legacyGreen(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("green", text, { stream }); } diff --git a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts new file mode 100644 index 0000000000..e1f5e7873b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { legacyAqua, legacyBold, legacyGreen, legacyRed, legacyYellow } from "./legacy-colors.ts"; + +// These tests only assert that each helper runs without throwing and returns a +// string containing the input text — actual color application depends on the +// stream's live TTY/NO_COLOR state, which isn't controllable from a test +// process. The behavior worth protecting here is the `stream` parameter +// threading through to `styleText`, not a specific ANSI byte sequence. +describe("legacy-colors", () => { + it("legacyAqua defaults to stderr when no stream is given", () => { + expect(legacyAqua("supabase")).toContain("supabase"); + }); + + it("legacyAqua accepts an explicit stream", () => { + expect(legacyAqua("supabase", process.stdout)).toContain("supabase"); + }); + + it("legacyBold defaults to stderr when no stream is given", () => { + expect(legacyBold("text")).toContain("text"); + }); + + it("legacyBold accepts an explicit stream", () => { + expect(legacyBold("text", process.stdout)).toContain("text"); + }); + + it("legacyYellow defaults to stderr when no stream is given", () => { + expect(legacyYellow("warning")).toContain("warning"); + }); + + it("legacyYellow accepts an explicit stream", () => { + expect(legacyYellow("warning", process.stdout)).toContain("warning"); + }); + + it("legacyRed defaults to stderr when no stream is given", () => { + expect(legacyRed("error")).toContain("error"); + }); + + it("legacyRed accepts an explicit stream", () => { + expect(legacyRed("error", process.stdout)).toContain("error"); + }); + + it("legacyGreen defaults to stderr when no stream is given", () => { + expect(legacyGreen("label")).toContain("label"); + }); + + it("legacyGreen accepts an explicit stream", () => { + expect(legacyGreen("label", process.stdout)).toContain("label"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts new file mode 100644 index 0000000000..d62db6a815 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts @@ -0,0 +1,266 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Effect, Exit, FileSystem, Path, Schema } from "effect"; + +import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +/** + * Cross-caller parity coverage: for a table of Go-parity misconfigurations, drives BOTH real + * pipelines — D (`legacyReadDbToml`, Effect/raw-TOML) and L (`legacyResolveLocalConfigValues`, + * `@supabase/config`-decoded) — and asserts they fail with the SAME shared error-message + * substring, since both now route through the single `legacyValidateResolvedConfig`. The two + * pipelines don't need byte-identical exception wrapping, just the same core Go-parity message + * text (`.toContain(...)` on both sides with the same expected string). + * + * D's harness replicates the `withConfig`/`read`/`failsWith` pattern from + * `legacy-db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully + * reproduced here rather than imported). L's harness replicates the `baseConfig`/`WORKDIR` + * pattern from `legacy-local-config-values.unit.test.ts` (same reasoning). + */ + +function withConfig(content: string) { + const dir = mkdtempSync(join(tmpdir(), "legacy-config-validate-parity-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(join(dir, "supabase", "config.toml"), content); + return dir; +} + +const readD = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +/** Drives D's real pipeline and asserts the failure message contains `message`. */ +function failsWithD(tomlLines: ReadonlyArray, message: string) { + return Effect.gen(function* () { + const dir = withConfig(tomlLines.join("\n")); + const exit = yield* readD(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(message); + } + rmSync(dir, { recursive: true, force: true }); + }); +} + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-config-validate-parity-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** Drives L's real pipeline and asserts the failure message contains `message`. */ +function failsWithL( + overrides: Record, + message: string, + document?: Readonly>, +) { + const config = baseConfig(overrides); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(message); +} + +interface ParityScenario { + readonly name: string; + readonly toml: ReadonlyArray; + readonly overrides: Record; + readonly document?: Readonly>; + readonly message: string; +} + +const scenarios: ReadonlyArray = [ + { + name: "db.port = 0", + toml: ["[db]", "port = 0"], + overrides: { db: { port: 0 } }, + message: "Missing required field in config: db.port", + }, + { + name: "db.major_version = 0", + toml: ["[db]", "major_version = 0"], + overrides: { db: { major_version: 0 } }, + message: "Missing required field in config: db.major_version", + }, + { + name: "db.major_version unsupported (16)", + toml: ["[db]", "major_version = 16"], + overrides: { db: { major_version: 16 } }, + message: "Failed reading config: Invalid db.major_version: 16.", + }, + { + name: "storage bucket name with an invalid pattern", + toml: ['[storage.buckets."bad#name"]'], + overrides: { storage: { buckets: { "bad#name": {} } } }, + message: + "Invalid Bucket name: bad#name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed.", + }, + { + name: "function slug with an invalid pattern", + toml: ["[functions.123]"], + overrides: { functions: { "123": {} } }, + message: + "Invalid Function name: 123. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens.", + }, + { + name: "edge_runtime.deno_version = 0", + toml: ["[edge_runtime]", "deno_version = 0"], + overrides: { edge_runtime: { deno_version: 0 } }, + message: "Missing required field in config: edge_runtime.deno_version", + }, + { + name: "edge_runtime.deno_version unsupported (3)", + toml: ["[edge_runtime]", "deno_version = 3"], + overrides: { edge_runtime: { deno_version: 3 } }, + message: "Failed reading config: Invalid edge_runtime.deno_version: 3.", + }, + { + name: "auth.site_url empty with auth enabled", + toml: ["[auth]", "enabled = true", 'site_url = ""'], + overrides: { auth: { enabled: true, site_url: "" } }, + message: "Missing required field in config: auth.site_url", + }, + { + name: "auth.captcha enabled without a provider", + toml: ["[auth.captcha]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true }, + }, + }, + message: "Missing required field in config: auth.captcha.provider", + }, + { + name: "auth.captcha enabled with a provider but no secret", + toml: ["[auth.captcha]", "enabled = true", 'provider = "hcaptcha"'], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha" }, + }, + }, + message: "Missing required field in config: auth.captcha.secret", + }, + { + name: "auth.hook.* with a badly-formatted secret", + toml: [ + "[auth.hook.custom_access_token]", + "enabled = true", + 'uri = "https://example.test/hook"', + 'secrets = "not-a-valid-secret"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + }, + }, + }, + // D's assertion goes through `JSON.stringify(exit.cause)`, which backslash-escapes the + // message's embedded double quotes — trim the substring to the quote-free prefix, same + // convention D's own suite uses for this message. + message: "auth.hook.custom_access_token.secrets must be formatted as", + }, + { + name: "auth.mfa.* enroll_enabled without verify_enabled", + toml: ["[auth.mfa.totp]", "enroll_enabled = true", "verify_enabled = false"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { totp: { enroll_enabled: true, verify_enabled: false } }, + }, + }, + message: "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + }, + { + name: "auth.third_party.* enabled without its required field", + toml: ["[auth.third_party.firebase]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { firebase: { enabled: true } }, + }, + }, + message: "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + }, + { + name: "auth.third_party.* more than one provider enabled", + toml: [ + "[auth.third_party.firebase]", + "enabled = true", + 'project_id = "proj"', + "[auth.third_party.auth0]", + "enabled = true", + 'tenant = "tenant"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { + firebase: { enabled: true, project_id: "proj" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }, + message: "Invalid config: Only one third_party provider allowed to be enabled at a time.", + }, + { + name: "auth.email.smtp present table missing a required field", + // Both pipelines read every smtp field straight off the raw TOML/document rather than a + // schema-decoded, always-defaulted value (Go's presence-based `enabled` default, + // config.go:743-748) — L needs the raw `document` (5th param) for this, matching D's raw + // smol-toml document. + toml: ["[auth.email.smtp]", 'user = "u"'], + overrides: { auth: { enabled: true, site_url: "http://localhost:3000" } }, + document: { auth: { email: { smtp: { user: "u" } } } }, + message: "Missing required field in config: auth.email.smtp.host", + }, + { + name: "experimental.pgdelta.format_options invalid JSON", + toml: ["[experimental.pgdelta]", 'format_options = "{not json"'], + overrides: { experimental: { pgdelta: { format_options: "{not json" } } }, + message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + }, +]; + +// Explicitly SKIPPED (only one caller runs the branch, or the branch isn't exercised the same +// way by both — see the module header in `legacy-config-validate.ts` for the full explicitly +// out-of-scope list): +// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared +// validator (`LegacyConfigValidationInput` has no fields for these at all). +// - `api.tls`, `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. +// - `experimental.webhooks` — L reads webhooks presence from a raw `document` that D's +// `legacyReadDbToml` doesn't thread through `legacyValidateResolvedConfig` at all (D has no +// `experimental` presence-based input field for this — verified: `legacy-db-config.toml-read.ts` +// never sets `experimental.webhooksPresent`/`webhooksEnabled` on its `LegacyExperimentalInput`), +// so this branch is D-unreachable and skipped here too. +describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { + for (const scenario of scenarios) { + it.effect(`${scenario.name}: D and L fail with the same message`, () => + Effect.gen(function* () { + yield* failsWithD(scenario.toml, scenario.message); + failsWithL(scenario.overrides, scenario.message, scenario.document); + }), + ); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts new file mode 100644 index 0000000000..f833f95300 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -0,0 +1,778 @@ +import { isAbsolute, join } from "node:path"; + +import { legacyGoUrlParse } from "./legacy-storage-url.ts"; + +/** + * Single home for Go's `Config.Validate` parity (`apps/cli-go/pkg/config/config.go:989-1192`), + * consolidating the two independent TypeScript ports of that logic: + * + * - **D** = `legacy-db-config.toml-read.ts` — raw smol-toml document + `EnvLookup`, + * Effect-based, fails with `LegacyDbConfigLoadError`. Feeds ~15 db/migration commands via + * `legacy-db-config.layer.ts`. + * - **L** = `legacy-local-config-values.ts` — decoded `@supabase/config` `ProjectConfig`, + * synchronous `node:fs`, throws plain `Error`. Feeds `status/status.values.ts` and + * `stop/stop.handler.ts`. + * + * **This file is the SINGLE home for `Config.Validate` parity going forward. + * Per-command reimplementations of any branch below are forbidden** — hoist here instead, + * per `apps/cli/AGENTS.md`'s "Hoist Before You Duplicate" policy. + * + * ## Status of this commit + * + * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and fully wired into BOTH callers: L + * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) and D + * (`legacy-db-config.toml-read.ts`'s `legacyReadDbToml`) each build a + * {@link LegacyConfigValidationInput} from their own decoded config (a `ProjectConfig` + raw + * `document` for L, a raw smol-toml document + `EnvLookup` for D) and call this function once, + * at the correct Go position. Wiring D through this module also fixed D's `db.major_version + * === 0` divergence (D used to fall through to the generic invalid-value message; it now + * throws the same "Missing required field in config: db.major_version" as Go and as L already + * did). + * + * ## Full eventual scope: every `Config.Validate` branch this module owns + * + * In Go's exact `Validate()` order (`config.go:989-1192`), first-failure-wins: + * + * | Go line(s) | Check | + * |--------------------------------|-------| + * | 990-991 | `project_id` required | + * | 1006-1027 | `api.port` / `api.tls.{cert,key}_path` presence (the actual file reads stay per-caller I/O) | + * | 1031-1062 | `db.port`, `db.major_version` (0 / 12 / 13-17 switch) | + * | 1064-1068, pattern @ 1549-1554 | `storage.buckets.*` names vs `LEGACY_BUCKET_NAME_PATTERN` | + * | 1070-1079 | `studio.port` / `studio.api_url` (L-only — D has no studio section) | + * | 1081-1085 | `local_smtp.port` (L-only) | + * | 1087-1153 | `auth.*` sub-sequence, in order: site_url (1088-1090); captcha enum + presence (1099-1109, enum itself decode-time per `auth.go:58-71`); signing_keys read (1110-1116, caller-side I/O); passkey/webauthn (1117-1134); hooks (1136-1138, checks @ 1453-1521, vs `LEGACY_HOOK_SECRET_PATTERN`); mfa (1139-1141, checks @ 1523-1534); email template/notification content-vs-content_path (1293-1323, caller-side I/O) + smtp (1325-1344); third_party (1151-1153, checks @ 1635-1683, vs `LEGACY_CLERK_DOMAIN_PATTERN`) | + * | 1159-1163, pattern @ 1539-1544 | `functions.*` slugs vs `LEGACY_FUNCTION_SLUG_PATTERN` | + * | 1164-1173 | `edge_runtime.deno_version` (0 / 1 / 2 switch) | + * | (decode-time enum) | `analytics.backend` must be `postgres`/`bigquery` | + * | 1175-1187 | `analytics.gcp_*` fields, gated on `backend === "bigquery"` | + * | 1846-1854 | `experimental.webhooks` / `experimental.pgdelta.format_options` | + * + * ## Explicitly OUT of scope forever (D-only, NEVER part of this module) + * + * - `remotes[*].project_id` pattern (`config.go:997-1001`, vs `LEGACY_PROJECT_REF_PATTERN`) — + * D's own remote-merge-phase check (`findInvalidRemoteProjectId`), never shared with L. + * - `auth.sms` (`config.go:1145-1147`/`1348-1417`) — stays 100% inline in D; L instead relies on + * `@supabase/config`'s `sms` schema enforcing the same provider-switch priority at decode time + * (`packages/config/src/auth/sms.ts`), since L decodes through that schema and D doesn't. + * - `auth.external` (`config.go:1148-1150`/`1419-1451`) — inline in BOTH D + * (`legacy-db-config.toml-read.ts`'s "B5: external providers") and L + * ({@link legacyResolveLocalConfigValues}'s `validateAuthExternalProviders`, called after this + * module's shared check, same ordering tradeoff as sms below) — never routed through this + * shared module, since it needs the RAW pre-decode document to see provider names + * `@supabase/config`'s schema doesn't model. + * - `auth.jwt_secret` length check (`apikeys.go:43-73`, `generateAPIKeys`) — each caller's own + * key-generation flow (D and L both already implement this separately), not part of + * `Config.Validate`'s pure-check set. + * + * `legacyExpandEnv` also stays in D (env-substitution machinery, not a validation leaf). + * + * ## Known ordering tradeoff (accepted — do not "fix") + * + * Go's real auth-block order is site_url → captcha → signing_keys[IO] → passkey → hooks → mfa → + * email[IO]+smtp → **sms → external** → third_party. Since sms/external are D-only and never + * part of this module, but third_party IS shared, D cannot call + * {@link legacyValidateResolvedConfig} in a way that preserves relative ordering across the + * sms/external ↔ third_party boundary without complex multi-phase calls. Decision (applies once + * D is wired up in a follow-up commit): D calls {@link legacyValidateResolvedConfig} ONCE with + * the full input (including third_party), positioned after D's own signing-keys and + * email-template I/O reads; D's inline sms/external checks then run AFTER that single call + * succeeds. This means: if third_party is broken, its error surfaces (matching Go); D's + * sms/external checks never run in that case. The only real behavior change from today: for the + * (untested, unrealistic) case where sms/external AND third_party are BOTH simultaneously + * broken in the same config.toml, Go/today's-D would report the sms/external error first, but + * the refactored D reports third_party's error first, since third_party is checked inside the + * single earlier shared call. This is an accepted, narrow, documented parity gap. + * + * The same category of tradeoff now also applies to L: `legacyResolveLocalConfigValues` calls + * {@link legacyValidateResolvedConfig} exactly ONCE, at the very end, after every value this + * module needs has been derived — including L's 3 I/O reads (signing keys, `api.tls` cert/key, + * email template/notification content), which stay at their original textual position (per-caller + * I/O, same as D's). Every pure check this module owns is therefore checked in Go's exact + * relative order against every OTHER pure check, but an I/O read that in L's source sits + * between two pure sections (e.g. the signing-keys read sits between the captcha check and the + * passkey/hooks/mfa/email/smtp/third_party checks) now effectively runs BEFORE any of those + * later pure checks, rather than interleaved at its original relative position — the same + * narrow, accepted, documented tradeoff, not something to "fix" by splitting this function into + * multiple calls. Every existing test constructs exactly one validation failure at a time, so + * this has zero effect on any real test. + */ + +// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 lowercase +// ASCII letters. Exported from this module (was private in D before this relocation) as the +// canonical home; D's `findInvalidRemoteProjectId` is today the only consumer — the +// `remotes[*].project_id` check itself stays D-only forever, see the module header above. +export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). +// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key +// during config load (`config.go:898-903`), aborting before any db command when a +// name does not match. The source string is reused verbatim in the error message via +// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Used by both D +// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`), and internally by +// {@link legacyValidateResolvedConfig}'s storage-bucket-names step (`config.go:1064-1068`). +export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; + +// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` +// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load +// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused +// in the message so it byte-matches Go's `funcSlugPattern.String()`. Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s function-slugs step (`config.go:1159-1163`). +export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; + +// Go's `hookSecretPattern` (`apps/cli-go/pkg/config/config.go:1436`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s hooks step (`config.go:1453-1521`). +export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; + +// Go's `clerkDomainPattern` (`apps/cli-go/pkg/config/config.go:1553`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s third_party step (`config.go:1635-1683`). +export const LEGACY_CLERK_DOMAIN_PATTERN = + /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; + +// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under +// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty +// string is `false`, and any other value is a parse error. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + +/** + * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly + * typed decode). Returns the bool, or `undefined` for a malformed value (which Go + * surfaces as a `failed to parse config` error). + * + * Used by both D (`legacy-db-config.toml-read.ts`'s `resolveBool`/`resolveBoolOrFail`) and + * L (`legacy-local-config-values.ts`'s `legacyEnvOverrideBool`) for their `SUPABASE_*` + * bool-flavored env overrides and TOML bool decoding. + */ +export function legacyParseGoBool(value: string): boolean | undefined { + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; + return undefined; +} + +/** + * Thrown by {@link legacyValidateResolvedConfig}. Deliberately does NOT override `.name` in a + * constructor — it stays the inherited `"Error"` — so `.toString()`/`.name`/`instanceof Error` + * checks are indistinguishable from a plain `new Error(message)`. Both D and L's existing + * callers/tests observe only `.message` (via `cause instanceof Error ? cause.message : ...` or + * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class + * is a byte-identical, purely internal refactor. + */ +export class LegacyConfigValidateError extends Error {} + +/** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ +export interface LegacyApiInput { + readonly enabled: boolean; + readonly port: number; + readonly tls: { + readonly enabled: boolean; + readonly certPath: string | undefined; + readonly keyPath: string | undefined; + }; +} + +/** `[db]`, post-env-override. Required — Go validates `db.port`/`db.major_version` unconditionally. */ +export interface LegacyDbInput { + readonly port: number; + readonly majorVersion: number; +} + +/** `[studio]`, post-env-override. L-only — D has no studio section. */ +export interface LegacyStudioInput { + readonly enabled: boolean; + readonly port: number; + readonly apiUrl: string; +} + +/** `[local_smtp]` (Go's `Inbucket`), post-env-override. L-only. */ +export interface LegacyLocalSmtpInput { + readonly enabled: boolean; + readonly port: number; +} + +/** `[auth.captcha]`. `provider` is deliberately `string | undefined`, not a narrow union — see + * divergence #2 in the module's port plan: D passes a raw, untyped TOML string (the enum check + * is live for D); L's `@supabase/config`-decoded value is already schema-narrowed to + * `"hcaptcha" | "turnstile" | undefined` before this function ever sees it, making the branch + * dead-but-harmless for L specifically, while still needing the same widened type to keep this + * field honest and reusable across both callers. + */ +export interface LegacyCaptchaInput { + readonly enabled: boolean; + readonly provider: string | undefined; + readonly secret: string | undefined; +} + +/** `[auth.passkey]` + `[auth.webauthn]`. Present iff `passkey.enabled === true`. */ +export interface LegacyPasskeyInput { + readonly webauthnPresent: boolean; + readonly rpId: string | undefined; + readonly rpOrigins: ReadonlyArray | undefined; +} + +/** One enabled `[auth.hook.]` entry. Caller pre-filters to enabled-only and pre-orders + * per Go's fixed hook-type iteration order (`config.go:1453-1485`). */ +export interface LegacyHookInput { + readonly type: + | "mfa_verification_attempt" + | "password_verification_attempt" + | "custom_access_token" + | "send_sms" + | "send_email" + | "before_user_created"; + /** Post-env-expand; `""` = absent. */ + readonly uri: string; + /** Post-env-expand; `""` = absent. */ + readonly secrets: string; +} + +/** One `[auth.mfa.]` entry. Caller pre-orders totp, phone, web_authn. */ +export interface LegacyMfaFactorInput { + readonly label: "totp" | "phone" | "web_authn"; + readonly enrollEnabled: boolean; + readonly verifyEnabled: boolean; +} + +/** `[auth.email.smtp]`. Present iff the raw TOML table itself is present (Go's presence-based + * `enabled` default, `config.go:743-748` — NOT the decoded, always-defaulted value). */ +export interface LegacySmtpInput { + readonly enabled: boolean; + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; +} + +/** One enabled `[auth.third_party.]` entry. Caller pre-filters to enabled-only and + * pre-orders per Go's fixed provider order (firebase, auth0, cognito, clerk, workos). */ +export interface LegacyThirdPartyInput { + readonly provider: "firebase" | "auth0" | "cognito" | "clerk" | "workos"; + /** `project_id` / `tenant` / `user_pool_id` / `domain` / `issuer_url`, per provider. */ + readonly requiredField: string; + /** cognito's second required field only. */ + readonly cognitoUserPoolRegion?: string; +} + +/** `[auth]`. Present in {@link LegacyConfigValidationInput} iff auth is enabled — matches Go's + * `if c.Auth.Enabled` gate wrapping this entire sub-sequence (`config.go:1087-1153`). */ +export interface LegacyAuthInput { + readonly siteUrl: string; + readonly captcha?: LegacyCaptchaInput; + readonly passkey?: LegacyPasskeyInput; + readonly hooks: ReadonlyArray; + readonly mfa: ReadonlyArray; + readonly smtp?: LegacySmtpInput; + readonly thirdParty: ReadonlyArray; +} + +/** `[analytics]`, post-env-override. Unconditional entry — internally gated on `enabled` + + * `backend === "bigquery"`. `backend` is `string | undefined` for the same dead-but-harmless-for-L + * reason as {@link LegacyCaptchaInput.provider} — see divergence #2. */ +export interface LegacyAnalyticsInput { + readonly enabled: boolean; + readonly backend: string | undefined; + readonly gcpProjectId: string; + readonly gcpProjectNumber: string; + readonly gcpJwtPath: string; +} + +/** `[experimental]`. Unconditional entry — internally gated. `webhooksPresent`/`webhooksEnabled` + * hinge on TOML-section PRESENCE (not the decoded, always-defaulted `enabled` value) — see + * `config.go:1846-1854` and the callers' own doc comments for why. */ +export interface LegacyExperimentalInput { + readonly webhooksPresent?: boolean; + readonly webhooksEnabled?: boolean; + readonly pgdeltaFormatOptions: string; +} + +/** + * Normalized POST-env-override primitives mirroring Go's decoded config, for VALIDATED fields + * only. Every section is OPTIONAL — an absent section means "this caller doesn't run that Go + * branch, skip it" (e.g. D omits `studio`/`localSmtp` entirely; both D and L omit `auth` when + * auth is disabled). See the module header for the full ported-branch table and out-of-scope + * list. + */ +export interface LegacyConfigValidationInput { + /** L only — D's `project_id` isn't part of `Config.Validate`'s shared surface. */ + readonly projectId?: string; + /** L only — D has no `[api]` section. */ + readonly api?: LegacyApiInput; + /** Both, unconditional in Go. */ + readonly db: LegacyDbInput; + /** Both, unconditional (`[]` = none). */ + readonly storageBucketNames: ReadonlyArray; + /** L only. */ + readonly studio?: LegacyStudioInput; + /** L only. */ + readonly localSmtp?: LegacyLocalSmtpInput; + /** Both, present iff auth is enabled. */ + readonly auth?: LegacyAuthInput; + /** Both, unconditional (`[]` = none). */ + readonly functionSlugs: ReadonlyArray; + /** Both, unconditional. */ + readonly edgeRuntimeDenoVersion: number; + /** Both, unconditional entry (internally gated). */ + readonly analytics: LegacyAnalyticsInput; + /** Both, unconditional entry (internally gated). */ + readonly experimental: LegacyExperimentalInput; +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Runs every `Config.Validate` branch this module owns (see the module header's table), in + * Go's exact order, first-failure-wins. Pure — no I/O, no Effect. Callers own their own + * per-section I/O reads (signing keys, `api.tls` cert/key, email template/notification content) + * at the correct Go position themselves, using the pure helpers exported below. + */ +export function legacyValidateResolvedConfig(input: LegacyConfigValidationInput): void { + // config.go:990-991 — checked FIRST, before every other field. + if (input.projectId !== undefined && input.projectId.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: project_id"); + } + + // config.go:1006-1027 — api.port / api.tls.{cert,key}_path, gated on api.enabled. The actual + // cert/key file reads are caller-side I/O (see legacyResolveApiTlsPath below); this only + // checks the "exactly one of cert/key set" presence rule. + if (input.api?.enabled) { + if (input.api.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: api.port"); + } + if (input.api.tls.enabled) { + const hasCert = input.api.tls.certPath !== undefined && input.api.tls.certPath.length > 0; + const hasKey = input.api.tls.keyPath !== undefined && input.api.tls.keyPath.length > 0; + if (hasCert && !hasKey) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.key_path"); + } + if (hasKey && !hasCert) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.cert_path"); + } + } + } + + // config.go:1031-1033 — db.port, unconditional, no `enabled` gate. + if (input.db.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.port"); + } + // config.go:1034-1062 — db.major_version switch: 0 / 12 have dedicated messages, 13/14/15/17 + // are supported, anything else is the generic invalid-value message. + if (input.db.majorVersion === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.major_version"); + } + if (input.db.majorVersion === 12) { + throw new LegacyConfigValidateError( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + if (![13, 14, 15, 17].includes(input.db.majorVersion)) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + ); + } + + // config.go:1064-1068, pattern @ 1549-1554 — every [storage.buckets.*] key, unconditional. + for (const name of input.storageBucketNames) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + throw new LegacyConfigValidateError( + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, + ); + } + } + + // config.go:1070-1079 — studio.port / studio.api_url, gated on studio.enabled. L-only. + if (input.studio?.enabled) { + if (input.studio.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: studio.port"); + } + try { + legacyGoUrlParse(input.studio.apiUrl); + } catch (cause) { + throw new LegacyConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); + } + } + + // config.go:1081-1085 — local_smtp.port, gated on local_smtp.enabled. L-only. + if (input.localSmtp?.enabled && input.localSmtp.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: local_smtp.port"); + } + + // config.go:1087-1153 — the auth.* sub-sequence, all inside `if c.Auth.Enabled`. + if (input.auth !== undefined) { + const auth = input.auth; + + // config.go:1088-1090 — auth.site_url. + if (auth.siteUrl.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: auth.site_url"); + } + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha. The provider enum check runs FIRST, + // regardless of `enabled` (it's actually a decode-time check in Go, reproduced here so both + // callers see it from one place); only then does the `enabled`-gated presence check run. + if (auth.captcha !== undefined) { + const provider = auth.captcha.provider; + if ( + provider !== undefined && + provider.length > 0 && + provider !== "hcaptcha" && + provider !== "turnstile" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + } + if (auth.captcha.enabled) { + if (auth.captcha.provider === undefined) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.provider", + ); + } + if (auth.captcha.secret === undefined || auth.captcha.secret.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.secret", + ); + } + } + } + + // config.go:1110-1116 — signing_keys read is caller-side I/O, not part of this function. + + // config.go:1117-1134 — auth.passkey / auth.webauthn. Caller only builds `passkey` when + // `[auth.passkey] enabled` is true. + if (auth.passkey !== undefined) { + if (!auth.passkey.webauthnPresent) { + throw new LegacyConfigValidateError( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + } + if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_id", + ); + } + if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_origins", + ); + } + } + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filtered to + // enabled-only and pre-ordered per Go's fixed hook-type iteration order. + for (const hook of auth.hooks) { + if (hook.uri.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.uri`, + ); + } + // Go calls `url.Parse` before the scheme switch (`config.go:1497-1499`) and fails the + // whole load on a malformed URI (e.g. an unterminated IPv6 host like `http://[::1`) — + // a bare scheme-prefix regex would accept that. Reuse `legacyGoUrlParse` (the same + // `net/url.Parse` port already used for `studio.api_url` above) instead of re-deriving + // a scheme by hand. + let scheme: string; + try { + scheme = legacyGoUrlParse(hook.uri).scheme; + } catch (cause) { + throw new LegacyConfigValidateError(`failed to parse template url: ${messageOf(cause)}`); + } + if (scheme === "http" || scheme === "https") { + if (hook.secrets.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.secrets`, + ); + } + for (const secret of hook.secrets.split("|")) { + if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, + ); + } + } + } else if (scheme === "pg-functions") { + if (hook.secrets.length > 0) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + ); + } + } else { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + ); + } + } + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, caller pre-ordered totp/phone/web_authn. + for (const factor of auth.mfa) { + if (factor.enrollEnabled && !factor.verifyEnabled) { + throw new LegacyConfigValidateError( + `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + ); + } + } + + // config.go:1293-1323 — email template/notification content read + exclusivity is + // caller-side, via legacyResolveEmailTemplateContentPath below. + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + if (auth.smtp !== undefined && auth.smtp.enabled) { + if (auth.smtp.host.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.host", + ); + } + if (auth.smtp.port === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.port", + ); + } + if (auth.smtp.user.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.user", + ); + } + if (auth.smtp.pass.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.pass", + ); + } + if (auth.smtp.adminEmail.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.admin_email", + ); + } + } + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, caller pre-filtered to + // enabled-only and pre-ordered firebase, auth0, cognito, clerk, workos. Each provider's + // required field(s) are checked as encountered; the "more than one enabled" check runs only + // after every entry has individually validated. + for (const thirdParty of auth.thirdParty) { + switch (thirdParty.provider) { + case "firebase": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + break; + } + case "auth0": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", + ); + } + break; + } + case "cognito": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ( + thirdParty.cognitoUserPoolRegion === undefined || + thirdParty.cognitoUserPoolRegion.length === 0 + ) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + break; + } + case "clerk": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk is enabled but without a domain.", + ); + } + if (!LEGACY_CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + break; + } + case "workos": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + break; + } + } + } + if (auth.thirdParty.length > 1) { + throw new LegacyConfigValidateError( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } + } + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + for (const slug of input.functionSlugs) { + if (!LEGACY_FUNCTION_SLUG_PATTERN.test(slug)) { + throw new LegacyConfigValidateError( + `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, + ); + } + } + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled. + if (input.edgeRuntimeDenoVersion === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: edge_runtime.deno_version", + ); + } + if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + ); + } + + // Decode-time enum (`LogflareBackend.UnmarshalText`, config.go:60-65) — reproduced here so + // both callers' env-override paths (which bypass their own decode-time schema guard) see it. + const backend = input.analytics.backend; + if ( + backend !== undefined && + backend.length > 0 && + backend !== "postgres" && + backend !== "bigquery" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + ); + } + // config.go:1175-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". + if (input.analytics.enabled && backend === "bigquery") { + if (input.analytics.gcpProjectId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_id", + ); + } + if (input.analytics.gcpProjectNumber.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_number", + ); + } + if (input.analytics.gcpJwtPath.length === 0) { + throw new LegacyConfigValidateError( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + } + } + + // config.go:1847-1848 — experimental.webhooks, hinges on TOML-section PRESENCE, not the + // decoded (always-defaulted) `enabled` value. + if (input.experimental.webhooksPresent === true && input.experimental.webhooksEnabled !== true) { + throw new LegacyConfigValidateError( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + } + // config.go:1850-1851 — experimental.pgdelta.format_options, must be valid JSON when set. + if ( + input.experimental.pgdeltaFormatOptions !== "" && + !isValidJson(input.experimental.pgdeltaFormatOptions) + ) { + throw new LegacyConfigValidateError( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + } +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +// ── signing keys (config.go:1110-1116, path rule config.go:877-878 filepath.IsAbs guard) ── + +/** Absolute → verbatim; relative → join(workdir, "supabase", p). */ +export function legacyResolveSigningKeysPath(workdir: string, signingKeysPath: string): string { + return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +} + +/** `failed to read signing keys: ${msg(cause)}` */ +export function legacySigningKeysReadErrorMessage(cause: unknown): string { + return `failed to read signing keys: ${messageOf(cause)}`; +} + +/** `failed to decode signing keys: ${msg(cause)}` */ +export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { + return `failed to decode signing keys: ${messageOf(cause)}`; +} +// D only asserts Array.isArray(JSON.parse(text)); L further decodes into LegacyJwk[] to sign +// with the first key — that JWK-specific decode/signing logic stays in L, unrelated to parity. + +// ── email template / notification (config.go:1293-1323) ── + +/** + * Pure exclusivity decision + path to read for one template/notification entry. Throws + * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and + * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to + * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even + * when `content` is ALSO set) always wins — Go does not reject "both set", `content_path` + * silently wins/overwrites. + * + * `base` is caller-resolved: TEMPLATE section → workdir; NOTIFICATION section → + * join(workdir, "supabase") (this asymmetry is real, intentional Go behavior — config.go's own + * FIXME comment flags it, do not "fix" it). + */ +export function legacyResolveEmailTemplateContentPath(args: { + readonly section: "template" | "notification"; + readonly name: string; + /** Post-env-expand; `""` = absent. */ + readonly contentPath: string; + /** Raw `content` key present in the TOML document. */ + readonly contentPresent: boolean; + readonly base: string; +}): string | undefined { + if (args.contentPath.length === 0) { + if (args.contentPresent) { + throw new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ); + } + return undefined; + } + return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); +} + +/** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ +export function legacyEmailContentPathReadErrorMessage( + section: "template" | "notification", + name: string, + cause: unknown, +): string { + return `Invalid config for auth.email.${section}.${name}.content_path: ${messageOf(cause)}`; +} + +// ── api.tls cert/key (config.go:1016-1026, path rule ~961-965, NO isAbsolute guard) ── + +/** Unconditional join(workdir, "supabase", p) — Go's path.Join absorbs a leading "/" too. */ +export function legacyResolveApiTlsPath(workdir: string, p: string): string { + return join(workdir, "supabase", p); +} + +/** `failed to read TLS cert: ${msg(cause)}` */ +export function legacyApiTlsCertReadErrorMessage(cause: unknown): string { + return `failed to read TLS cert: ${messageOf(cause)}`; +} + +/** `failed to read TLS key: ${msg(cause)}` */ +export function legacyApiTlsKeyReadErrorMessage(cause: unknown): string { + return `failed to read TLS key: ${messageOf(cause)}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts new file mode 100644 index 0000000000..c66d1db6c9 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts @@ -0,0 +1,1034 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + LEGACY_PROJECT_REF_PATTERN, + type LegacyAuthInput, + type LegacyConfigValidationInput, + legacyParseGoBool, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; + +// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in an earlier +// commit (see the module header in `legacy-config-validate.ts`). The bulk of `Config.Validate` +// behavioral coverage — direct calls to `legacyValidateResolvedConfig`, covering every branch +// this module owns regardless of which caller (D or L) exercises it — lives further down this +// file; it was consolidated here from `legacy-local-config-values.unit.test.ts`, where it used +// to be exercised only indirectly through `legacyResolveLocalConfigValues`. + +describe("legacyParseGoBool", () => { + it("accepts Go's strconv.ParseBool true forms", () => { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(legacyParseGoBool(value)).toBe(true); + } + }); + + it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { + expect(legacyParseGoBool(value)).toBe(false); + } + }); + + it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { + expect(legacyParseGoBool("yes")).toBeUndefined(); + expect(legacyParseGoBool("2")).toBeUndefined(); + }); +}); + +describe("LEGACY_PROJECT_REF_PATTERN", () => { + it("matches a valid 20-character lowercase project ref", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("abcdefghijklmnopqrst")).toBe(true); + }); + + it("rejects refs of the wrong length or case", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("short")).toBe(false); + expect(LEGACY_PROJECT_REF_PATTERN.test("ABCDEFGHIJKLMNOPQRST")).toBe(false); + }); +}); + +describe("LEGACY_BUCKET_NAME_PATTERN", () => { + it("matches Go-legal bucket name characters", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); + }); + + it("rejects characters outside Go's bucketNamePattern", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); + }); +}); + +describe("LEGACY_FUNCTION_SLUG_PATTERN", () => { + it("matches a valid function slug (letters, digits, _ and -)", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("my-function")).toBe(true); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("function_1")).toBe(true); + }); + + it("rejects a slug that doesn't start with a letter", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("123")).toBe(false); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("1bad")).toBe(false); + }); +}); + +describe("LEGACY_HOOK_SECRET_PATTERN", () => { + it("matches a valid v1,whsec_ secret", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); + }); + + it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); + }); +}); + +describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { + it("matches a valid clerk.example.com domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("clerk.example.com")).toBe(true); + }); + + it("matches a valid .clerk.accounts.dev domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); + }); + + it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); + }); +}); + +/** + * A trivially-passing full input. Every test below spreads/overrides only the field(s) its + * check cares about, matching the fixture-building style of `legacy-local-config-values.unit + * .test.ts`'s own `baseConfig()` helper. + */ +function minimalInput( + overrides: Partial = {}, +): LegacyConfigValidationInput { + return { + db: { port: 5432, majorVersion: 17 }, + storageBucketNames: [], + functionSlugs: [], + edgeRuntimeDenoVersion: 2, + analytics: { + enabled: false, + backend: undefined, + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + experimental: { pgdeltaFormatOptions: "" }, + ...overrides, + }; +} + +/** A trivially-passing `[auth]` section — auth enabled, nothing else configured. */ +function minimalAuthInput(overrides: Partial = {}): LegacyAuthInput { + return { + siteUrl: "http://localhost:3000", + hooks: [], + mfa: [], + thirdParty: [], + ...overrides, + }; +} + +// Moved from `legacy-local-config-values.unit.test.ts`: these describe blocks exercise checks +// that now live entirely inside `legacyValidateResolvedConfig` and can be phrased as direct +// calls with a hand-built `LegacyConfigValidationInput` — no `ProjectConfig`/schema decode, no +// env-override machinery, no file I/O, no `document` threading. Everything that still needs +// one of those (value derivation, env-override mechanics, the 3 I/O checks' actual file reads) +// stays in `legacy-local-config-values.unit.test.ts`. +describe("legacyValidateResolvedConfig", () => { + // config.go:1034-1062 — db.major_version switch. The env-override + // (SUPABASE_DB_MAJOR_VERSION) variants stay in legacy-local-config-values.unit.test.ts. + describe("db.major_version", () => { + it("rejects a configured major_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 0 } })), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), + ).toThrow("Postgres version 12.x is unsupported."); + }); + + it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion } })), + ).not.toThrow(); + }); + + it("rejects an unsupported major_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 16 } })), + ).toThrow("Failed reading config: Invalid db.major_version: 16."); + }); + }); + + // config.go:1064-1068, pattern @ 1549-1554 — unconditional, no storage.enabled-style gate. + describe("storage.buckets", () => { + it("rejects a bucket name Go's ValidateBucketName refuses", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), + ).toThrow("Invalid Bucket name: bad/name."); + }); + + it("does not throw for a valid bucket name", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["avatars.public"] })), + ).not.toThrow(); + }); + + it("does not throw when no buckets are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled (there is no such field on LegacyConfigValidationInput at all). The + // env-override (SUPABASE_EDGE_RUNTIME_DENO_VERSION) variants stay in + // legacy-local-config-values.unit.test.ts. + describe("edge_runtime.deno_version", () => { + it("rejects a configured deno_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + + it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: denoVersion })), + ).not.toThrow(); + }); + + it("rejects an unsupported deno_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 3 })), + ).toThrow("Failed reading config: Invalid edge_runtime.deno_version: 3."); + }); + + it("rejects an invalid deno_version even when edge_runtime is disabled", () => { + // There is no `edgeRuntime.enabled`-style gate on `LegacyConfigValidationInput` at + // all — this is identical to the "rejects a configured deno_version of 0" case above, + // which is itself the point: Go never gates this check on edge_runtime.enabled. + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + }); + + // config.go:1174-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". The + // env-override (SUPABASE_ANALYTICS_*) variants stay in legacy-local-config-values.unit.test.ts. + describe("analytics (BigQuery backend required fields)", () => { + it("rejects an enabled bigquery backend without gcp_project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_id"); + }); + + it("rejects an enabled bigquery backend without gcp_project_number", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_number"); + }); + + it("rejects an enabled bigquery backend without gcp_jwt_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "", + }, + }), + ), + ).toThrow( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + }); + + it("does not throw when an enabled bigquery backend has all three GCP fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "gcp.json", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: false, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + }); + + // config.go:1846-1854 — experimental.validate(), unconditional, internally gated. + describe("experimental.*", () => { + it("rejects a present [experimental.webhooks] section with enabled omitted", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { webhooksPresent: true, pgdeltaFormatOptions: "" } }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("rejects a present [experimental.webhooks] section with enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: false, + pgdeltaFormatOptions: "", + }, + }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("does not throw when [experimental.webhooks] enabled = true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: true, + pgdeltaFormatOptions: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [experimental.webhooks] is absent entirely", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects invalid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: "{not json" } }), + ), + ).toThrow("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); + }); + + it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: '{"keywordCase":"upper"}' } }), + ), + ).not.toThrow(); + }); + + it("does not throw when experimental.pgdelta.format_options is unset", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ experimental: { pgdeltaFormatOptions: "" } })), + ).not.toThrow(); + }); + }); + + // config.go:1088-1090 — auth.site_url, checked first inside `if c.Auth.Enabled`. An absent + // `auth` section on `LegacyConfigValidationInput` IS "auth disabled" from this function's + // perspective. The SUPABASE_AUTH_ENABLED/SUPABASE_AUTH_SITE_URL env-override variants stay in + // legacy-local-config-values.unit.test.ts. + describe("auth.site_url", () => { + it("rejects an explicit empty site_url when auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ siteUrl: "" }) })), + ).toThrow("Missing required field in config: auth.site_url"); + }); + + it("does not throw when site_url is set and auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ siteUrl: "http://localhost:3000" }) }), + ), + ).not.toThrow(); + }); + + it("does not throw an explicit empty site_url when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha, checked right after auth.site_url. + describe("auth.captcha", () => { + it("rejects an enabled captcha without a provider", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: undefined, secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("rejects an enabled captcha with a provider but no secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.secret"); + }); + + it("does not throw when an enabled captcha has both provider and secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when captcha is disabled, however incomplete", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: undefined, secret: undefined }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1117-1134 — auth.passkey/auth.webauthn, right after the (caller-side) signing-keys + // read. + describe("auth.passkey / auth.webauthn", () => { + it("rejects passkey.enabled without an [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: false, rpId: undefined, rpOrigins: undefined }, + }), + }), + ), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: undefined, + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: true, rpId: "localhost", rpOrigins: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: "localhost", + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when passkey is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ passkey: undefined }) }), + ), + ).not.toThrow(); + }); + + it("does not throw when auth carries no passkey data at all", () => { + // Distinct from the previous test only in the original (L-level) caller's derivation — + // "webauthn absent from the document" vs. "no document was threaded through at all". + // Both collapse to `passkey: undefined` at this shared, direct-call layer. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled passkey without webauthn when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + describe("auth.email.smtp", () => { + it("rejects a present [auth.email.smtp] table with no fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { enabled: true, host: "", port: 0, user: "", pass: "", adminEmail: "" }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.port"); + }); + + it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: false, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is a complete table", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 587, + user: "user", + pass: "pass", + adminEmail: "admin@example.com", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ smtp: undefined }) })), + ).not.toThrow(); + }); + + it("does not throw when auth carries no smtp data at all", () => { + // See the equivalent passkey note above — both collapse to `smtp: undefined` here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filters to enabled-only. + describe("auth.hook.*", () => { + it("rejects an enabled hook without a uri", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "", secrets: "" }], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.uri"); + }); + + it("rejects an http(s) hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { type: "custom_access_token", uri: "https://example.test/hook", secrets: "" }, + ], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); + }); + + it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + ], + }), + }), + ), + ).toThrow( + 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', + ); + }); + + it("does not throw for a valid http(s) hook secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a pg-functions hook uri with secrets set", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.secrets is unsupported for pg-functions URI"); + }); + + it("does not throw for a pg-functions hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: "", + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a hook uri with an unsupported scheme", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "ftp://example.test/hook", secrets: "" }], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); + + // Go calls `url.Parse` before the scheme switch (config.go:1497-1499) and fails the whole + // load on a malformed URI, rather than treating any `http:`/`https:` prefix as valid. + it("rejects a hook uri that fails Go's url.Parse (malformed IPv6 host)", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "http://[::1", secrets: "" }], + }), + }), + ), + ).toThrow("failed to parse template url:"); + }); + + it("does not throw for a disabled hook, however incomplete", () => { + // The caller pre-filters to enabled-only hooks — a disabled hook is simply absent from + // `hooks`, matching an empty array here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ hooks: [] }) })), + ).not.toThrow(); + }); + + it("does not throw an enabled hook without a uri when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, fixed totp/phone/web_authn order. + describe("auth.mfa.*", () => { + it.each([ + ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], + ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], + ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], + ] as const)("rejects %s enroll_enabled without verify_enabled", (label, message) => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label, enrollEnabled: true, verifyEnabled: false }], + }), + }), + ), + ).toThrow(message); + }); + + it("does not throw when enroll_enabled and verify_enabled are both true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label: "totp", enrollEnabled: true, verifyEnabled: true }], + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, fixed provider order, caller + // pre-filters to enabled-only. + describe("auth.third_party.*", () => { + it("rejects firebase enabled without a project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "firebase", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.firebase is enabled but without a project_id."); + }); + + it("rejects auth0 enabled without a tenant", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "auth0", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + }); + + it("rejects aws_cognito enabled without a user_pool_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id."); + }); + + it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "cognito", requiredField: "pool-1", cognitoUserPoolRegion: undefined }, + ], + }), + }), + ), + ).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + }); + + it("rejects clerk enabled without a domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "clerk", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); + }); + + it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "not-a-clerk-domain" }], + }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk has invalid domain"); + }); + + it("does not throw for a valid clerk.example.com domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "clerk.example.com" }], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects workos enabled without an issuer_url", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "workos", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.workos is enabled but without a issuer_url."); + }); + + it("rejects more than one third_party provider enabled at once", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "firebase", requiredField: "proj" }, + { provider: "auth0", requiredField: "tenant" }, + ], + }), + }), + ), + ).toThrow("Invalid config: Only one third_party provider allowed to be enabled at a time."); + }); + + it("does not throw when no third_party provider is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + describe("functions.*", () => { + it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + + it("does not throw for a valid function slug", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["hello-world_v2"] })), + ).not.toThrow(); + }); + + it("does not throw when no functions are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects an invalid function slug even when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + }); + + // config.go:1006-1027 — only the "exactly one of cert/key set" presence rule; the actual + // file reads and the disabled-skip/env-override tests stay in + // legacy-local-config-values.unit.test.ts. + describe("api.tls", () => { + it("rejects cert_path set without key_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: "cert.pem", keyPath: undefined }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.key_path"); + }); + + it("rejects key_path set without cert_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: undefined, keyPath: "key.pem" }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.cert_path"); + }); + }); + + // Direct-shared-level regression/divergence coverage (Part C) — behavior that's either new + // (the D fix) or only meaningfully testable at this exact layer (the captcha enum), not a + // move from either caller's own suite. + describe("Config.Validate divergence regression coverage", () => { + it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + // D used to fall through to the generic "Invalid db.major_version: 0" message; both D + // and L now go through this exact branch, so pin it directly here too, not just via + // D's/L's own suites. + expect(() => + legacyValidateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + // This scenario is only meaningful at this direct shared-validator level: it's + // unreachable through L's real ProjectConfig-typed flow — `@supabase/config`'s schema + // (packages/config/src/auth/captcha.ts, stringEnum(["hcaptcha", "turnstile"])) already + // narrows `provider` to "hcaptcha" | "turnstile" | undefined before it ever reaches + // `legacyResolveLocalConfigValues`, so an invalid provider value would fail schema + // decoding first, on a completely different code path. D's real TOML flow CAN reach + // this branch (an untyped raw string) — D's own suite + // (`legacy-db-config.toml-read.unit.test.ts`) covers that separately. This test's job is + // only to pin the shared function's own behavior directly. + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: "not-a-real-provider", secret: undefined }, + }), + }), + ), + ).toThrow( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 504deb471e..5a269abf87 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -51,6 +51,97 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { return false; } +/** + * Go's `utils.SuggestEnvVar` (`internal/utils/connect.go:191`): the hint shown when + * a connection fails on password authentication, pointing users at the + * `SUPABASE_DB_PASSWORD` env var. + */ +export const LEGACY_SUGGEST_ENV_VAR = + "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; + +/** Context the connect-suggestion needs but cannot derive from the error alone. */ +export interface LegacyConnectSuggestionContext { + /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ + readonly dashboardUrl: string; + /** Active profile name (Go's `CurrentProfile.Name`). */ + readonly profileName: string; + /** Whether `--debug` is set (Go's `viper.GetBool("DEBUG")`). */ + readonly debug: boolean; +} + +/** + * Flatten an error's `cause` chain and any `AggregateError.errors` into a single + * searchable string of every nested `message` and `code`. The `@effect/sql` + * `SqlError` wraps the node-postgres / node `net` driver error on its `cause`; a + * multi-address dial wraps an `AggregateError` whose `errors[]` carry the per-IP + * `ECONNREFUSED` / `ENETUNREACH` system errors. Including the `code` strings lets + * the matcher key off node's `ECONNREFUSED` the way Go keys off pgconn's + * `connect: connection refused`. + */ +function legacyCollectConnectErrorText(error: unknown): string { + const parts: string[] = []; + const seen = new Set(); + const visit = (node: unknown, depth: number): void => { + if (depth > 8 || typeof node !== "object" || node === null || seen.has(node)) return; + seen.add(node); + const message = Reflect.get(node, "message"); + if (typeof message === "string") parts.push(message); + const code = Reflect.get(node, "code"); + if (typeof code === "string") parts.push(code); + visit(Reflect.get(node, "cause"), depth + 1); + const errors = Reflect.get(node, "errors"); + if (Array.isArray(errors)) for (const child of errors) visit(child, depth + 1); + }; + visit(error, 0); + return parts.join("\n"); +} + +/** + * Port of Go's `SetConnectSuggestion` (`internal/utils/connect.go:313-335`): map a + * Postgres connect failure to an actionable hint that replaces the generic + * "Try rerunning the command with --debug" suggestion. Go matches `pgconn`'s + * error text; this matches the equivalent node-postgres / node `net` driver text + * and codes (e.g. `ECONNREFUSED` for `connect: connection refused`) gathered from + * the `SqlError` cause/aggregate chain. The branch order mirrors Go's `if/else if`. + * Returns `undefined` when no specific suggestion applies (the caller then falls + * back to the generic suggestion, like Go leaving `CmdSuggestion` empty). + */ +export function legacyConnectSuggestion( + error: unknown, + ctx: LegacyConnectSuggestionContext, +): string | undefined { + const text = legacyCollectConnectErrorText(error); + // connect: connection refused / Address not in tenant allow_list → network restrictions. + if ( + text.includes("ECONNREFUSED") || + text.includes("connection refused") || + text.includes("Address not in tenant allow_list") + ) { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + } + // SSL connection is required (only under --debug, which disables TLS). + if (text.includes("SSL connection is required") && ctx.debug) { + return "SSL connection is not supported with --debug flag"; + } + // Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth"; + // node-postgres surfaces the server's `28P01` "password authentication failed"). + if ( + text.includes("SCRAM exchange: Wrong password") || + text.includes("failed SASL auth") || + text.includes("password authentication failed") + ) { + return LEGACY_SUGGEST_ENV_VAR; + } + if (legacyIsIPv6ConnectivityError(text)) { + return legacyIpv6Suggestion(); + } + // no route to host / Tenant or user not found → wrong profile. + if (text.includes("no route to host") || text.includes("Tenant or user not found")) { + return `Make sure your project exists on profile: ${ctx.profileName}`; + } + return undefined; +} + function hasStringCode(error: unknown): error is { readonly code: string; readonly address?: unknown; diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 05ff60186c..844cda45e0 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { + LEGACY_SUGGEST_ENV_VAR, + legacyConnectSuggestion, + legacyIpv6Suggestion, legacyIsIPv6ConnectivityError, legacyIsIPv6ConnectivityErrorCause, } from "./legacy-connect-errors.ts"; @@ -44,6 +47,77 @@ describe("legacyIsIPv6ConnectivityError", () => { }); }); +describe("legacyConnectSuggestion", () => { + const ctx = { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + } as const; + + // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address + // dial wraps an AggregateError whose `.errors[]` carry the per-IP system errors. + const sqlError = (cause: unknown) => + Object.assign(new Error("PgClient: Failed to connect"), { cause }); + const systemError = (message: string, code: string) => + Object.assign(new Error(message), { code }); + + it("maps a refused connection (node ECONNREFUSED) to the network-restrictions hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.\nhttps://supabase.com/dashboard/project/_/database/settings", + ); + }); + + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { + const err = sqlError( + Object.assign(new AggregateError([], "all attempts failed"), { + errors: [systemError("connect ECONNREFUSED [::1]:54322", "ECONNREFUSED")], + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toContain( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.", + ); + }); + + it("maps the pooler allow_list rejection to the network-restrictions hint", () => { + const err = sqlError(new Error("Address not in tenant allow_list")); + expect(legacyConnectSuggestion(err, ctx)).toContain("Network Restrictions and Network Bans"); + }); + + it("maps a password-auth failure to the env-var suggestion", () => { + const err = sqlError( + Object.assign(new Error('password authentication failed for user "postgres"'), { + code: "28P01", + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toBe(LEGACY_SUGGEST_ENV_VAR); + }); + + it("suggests the --debug SSL note only under --debug", () => { + const err = sqlError(new Error("SSL connection is required")); + expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); + expect(legacyConnectSuggestion(err, { ...ctx, debug: true })).toBe( + "SSL connection is not supported with --debug flag", + ); + }); + + it("maps an IPv6-only connectivity failure to the IPv6 pooler suggestion", () => { + const err = sqlError(new Error("dial tcp: network is unreachable")); + expect(legacyConnectSuggestion(err, ctx)).toBe(legacyIpv6Suggestion()); + }); + + it("maps a tenant-not-found error to the wrong-profile hint", () => { + const err = sqlError(new Error("Tenant or user not found")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your project exists on profile: supabase", + ); + }); + + it("returns undefined for an unrecognized connect error", () => { + expect(legacyConnectSuggestion(sqlError(new Error("some other failure")), ctx)).toBeUndefined(); + }); +}); + describe("legacyIsIPv6ConnectivityErrorCause", () => { it("classifies Node getaddrinfo and network-unreachable errors", () => { expect( diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 3bf5f4244f..53bfb6e947 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -14,6 +14,36 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner type Spawner = ChildProcessSpawner["Service"]; +/** + * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither + * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. + * Not exported: callers never need to match on this type directly, they fold it + * into their own tagged error via {@link legacyDescribeContainerCliFailure} so + * the "no runtime found" root cause survives instead of collapsing into a + * generic "failed to ..." message. + */ +class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( + "LegacyContainerRuntimeNotFoundError", +)<{ + readonly message: string; +}> {} + +const RUNTIME_NOT_FOUND_MESSAGE = + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; + +/** + * Renders a caller-facing suffix for a `spawnContainerCli`/`containerCliExitCode` + * failure: the clear "neither runtime found" message when that's the cause, + * otherwise the underlying cause's own message (falling back to `String(cause)` + * for non-`Error` causes) so callers never collapse a real failure reason into a + * bare, uninformative "failed to ..." string. + */ +export function legacyDescribeContainerCliFailure(cause: unknown): string { + if (cause instanceof LegacyContainerRuntimeNotFoundError) return cause.message; + if (cause instanceof Error) return cause.message; + return String(cause); +} + /** * Spawn a container-CLI command and return the process handle. Use when the * caller needs to read stdout/stderr or await the exit code itself. @@ -25,17 +55,115 @@ export const spawnContainerCli = ( ) => spawner .spawn(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.spawn(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .spawn(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); /** * Run a container-CLI command and resolve to its exit code, mirroring the * spawner's `exitCode` convenience for callers that only need the status. + * + * `podmanArgs` lets a caller pass different argv to the Podman fallback than to + * Docker, for the rare case where the two aren't drop-in compatible on a given + * subcommand (e.g. `volume prune --all` — Docker-only, see + * `stop.handler.ts`'s volume-prune call). Defaults to reusing `args` unchanged, + * which is correct for every other call site. */ export const containerCliExitCode = ( spawner: Spawner, args: ReadonlyArray, options?: ChildProcess.CommandOptions, + podmanArgs?: ReadonlyArray, ) => spawner .exitCode(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.exitCode(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); + +function collectDockerCliText(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +/** + * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, + * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on + * `.` and compares the parts numerically, component by component — not a + * naive string/float compare, which would misorder e.g. `"1.9"` vs `"1.10"`. + */ +function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean { + const parts = version.split(".").map((part) => Number.parseInt(part, 10)); + const minParts = minVersion.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(parts.length, minParts.length); index++) { + const part = parts[index] ?? 0; + const minPart = minParts[index] ?? 0; + if (part !== minPart) return part > minPart; + } + return true; +} + +/** + * Docker CLI's own `volume prune --all` flag is annotated `version: "1.42"` + * (vendored `docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and + * enforced by Cobra's `Args` validator *before* `RunE` runs + * (`cmd/docker/docker.go:659-660`): against a daemon with a lower negotiated + * API version, `docker volume prune --all ...` exits nonzero without pruning + * anything at all, rather than degrading gracefully. Go avoids ever hitting + * that by gating the equivalent `all=true` filter on + * `Docker.ClientVersion() >= "1.42"` (`docker.go:126-133`); there is no + * persistent Engine API client here to ask, so this asks the `docker` CLI + * itself via `docker version`. + * + * Deliberately does not fall back to Podman like {@link containerCliExitCode} + * does: Podman's `volume prune` never has an `--all` flag to gate in the + * first place (callers already omit it from their Podman argv + * unconditionally), so this check is meaningless on that path. Resolves to + * `false` (omit `--all`, matching how a pre-1.42 daemon's own `volume prune` + * already prunes every unused volume without it) whenever `docker` can't be + * spawned, its `version` call fails, or the reported version can't be read — + * the side that can never turn into a hard failure of the prune call itself. + */ +export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }), + ); + const [exitCode, stdout] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return false; + const version = stdout.trim(); + return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + }), + ).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index 7c6f97aeca..e80dbf5ae8 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { containerCliExitCode, spawnContainerCli } from "./legacy-container-cli.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + legacyDockerSupportsVolumePruneAllFlag, + spawnContainerCli, +} from "./legacy-container-cli.ts"; -function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode?: number } = {}) { +function mockSpawner( + opts: { + readonly dockerMissing?: boolean; + readonly bothMissing?: boolean; + readonly exitCode?: number; + readonly stdout?: string; + } = {}, +) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -13,13 +25,13 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ command: cmd, args }); - if (opts.dockerMissing && cmd === "docker") { + if ((opts.dockerMissing && cmd === "docker") || opts.bothMissing === true) { return yield* Effect.fail( PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn", - description: "docker not found", + description: `${cmd} not found`, }), ); } @@ -29,7 +41,10 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.empty, + stdout: + opts.stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + : Stream.empty, stderr: Stream.empty, all: Stream.empty, exitCode: Deferred.await(exitDeferred), @@ -98,4 +113,108 @@ describe("containerCliExitCode", () => { }), ); }); + + it.live("fails with a clear message when neither docker nor podman can be spawned", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["image", "inspect", "img"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toBe( + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH", + ); + }), + ); + }); +}); + +describe("legacyDockerSupportsVolumePruneAllFlag", () => { + it.live("returns true when the daemon reports an API version at or above 1.42", () => { + const mock = mockSpawner({ stdout: "1.42" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, + ]); + }), + ); + }); + + it.live("returns true for a version comfortably above 1.42", () => { + const mock = mockSpawner({ stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + }), + ); + }); + + it.live("returns false when the daemon reports an API version below 1.42", () => { + const mock = mockSpawner({ stdout: "1.41" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("compares version components numerically, not lexicographically", () => { + // A naive string compare would misorder "1.9" as greater than "1.42" — this + // guards the numeric, component-by-component comparison instead. + const mock = mockSpawner({ stdout: "1.9" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the version command exits non-zero", () => { + const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the reported version is empty", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false without falling back to podman when docker cannot be spawned", () => { + const mock = mockSpawner({ dockerMissing: true }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + expect(mock.spawned.map((entry) => entry.command)).toEqual(["docker"]); + }), + ); + }); +}); + +describe("legacyDescribeContainerCliFailure", () => { + it.live("describes a both-runtimes-missing failure with its clear message", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["ps"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toContain("docker: command not found"); + }), + ); + }); + + it("falls back to an Error instance's own message", () => { + expect(legacyDescribeContainerCliFailure(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyDescribeContainerCliFailure("boom")).toBe("boom"); + expect(legacyDescribeContainerCliFailure(42)).toBe("42"); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index 78549ac06f..c277d2930e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -158,6 +158,13 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "postgres", password: "hunter2", database: "postgres", + // The resolver attaches the connect-failure suggestion context (Go's + // ambient CurrentProfile) to every resolved connection. + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(true); rmSync(dir, { recursive: true, force: true }); @@ -206,6 +213,11 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "alice", password: "p@ss", database: "appdb", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(false); rmSync(dir, { recursive: true, force: true }); @@ -495,6 +507,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.ref).toEqual(Option.some(adHocRef)); expect(requests).toEqual([ @@ -648,6 +665,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); } expect(requests).toEqual([ @@ -785,6 +807,11 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { user: `postgres.${linkedRef}`, password: "linked-password", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); } expect(requests).toEqual([ diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 9bc7e00f45..45714feb97 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -20,6 +20,10 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; +import { + type LegacyConnectSuggestionContext, + LEGACY_SUGGEST_ENV_VAR, +} from "./legacy-connect-errors.ts"; import { LegacyDbConnection, type LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "./legacy-identity-stitch.ts"; import { @@ -44,9 +48,6 @@ const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; const BACKOFF_INITIAL = Duration.seconds(3); const BACKOFF_MAX = Duration.seconds(60); -// Go: utils.SuggestEnvVar (`apps/cli-go/internal/utils/connect.go:174`). -const SUGGEST_ENV_VAR = - "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; const loginRoleErrorMapper = mapLegacyHttpError({ networkError: Errors.LegacyDbConfigLoginRoleNetworkError, @@ -106,6 +107,16 @@ export const legacyDbConfigLayer = Layer.effect( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion` + // reads the ambient `CurrentProfile` + `viper.GetBool("DEBUG")`). Snapshot it once + // and attach it to every resolved connection so the driver layer can render Go's + // hint on a refused/auth/IPv6 connect error. + const suggestionContext: LegacyConnectSuggestionContext = { + dashboardUrl: cliConfig.dashboardUrl, + profileName: cliConfig.profile, + debug: yield* LegacyDebugFlag, + }; + // Capture the ambient services the Management API stack needs, so the // lazily-built linked stack is fully self-provided and `resolve`'s R stays // `never` (handler tests can mock this resolver without wiring the whole @@ -203,7 +214,7 @@ export const legacyDbConfigLayer = Layer.effect( return Effect.fail( new Errors.LegacyDbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, + suggestion: LEGACY_SUGGEST_ENV_VAR, }), ); } @@ -549,6 +560,18 @@ export const legacyDbConfigLayer = Layer.effect( ); }); - return LegacyDbConfigResolver.of({ resolve, resolvePoolerFallback }); + // Attach the connect-failure suggestion context to every resolved connection in + // one place (Go sets it ambiently via `CurrentProfile`), so each connecting + // command inherits Go's `SetConnectSuggestion` hint without per-call-site wiring. + const withSuggestion = (conn: LegacyPgConnInput): LegacyPgConnInput => ({ + ...conn, + suggestionContext, + }); + return LegacyDbConfigResolver.of({ + resolve: (flags) => + resolve(flags).pipe(Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) }))), + resolvePoolerFallback: (flags) => + resolvePoolerFallback(flags).pipe(Effect.map(Option.map(withSuggestion))), + }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 0e4bc28028..0e11809a56 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,5 +1,26 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; +import { + LEGACY_PROJECT_REF_PATTERN, + type LegacyAnalyticsInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { @@ -77,6 +98,11 @@ interface LegacyDbTomlValues { readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ readonly vault: ReadonlyArray; + /** + * The matched `[remotes.]` block name when a linked ref merged its override + * (Go's `Loading config override: [remotes.]` line), else `undefined`. + */ + readonly appliedRemote: string | undefined; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -180,6 +206,12 @@ function deepMergeDoc(base: RawDoc, override: RawDoc): RawDoc { */ interface LegacyRemoteOverride { readonly doc: RawDoc | undefined; + /** + * The name of the matched `[remotes.]` block whose `project_id` equals the + * resolved ref, or `undefined` when no block matched. Callers echo Go's + * `Loading config override: [remotes.]` stderr line from this. + */ + readonly appliedRemote?: string; /** * The config keys the matched remote block contributed at viper's OVERRIDE tier. Go's * `mergeRemoteConfig` applies every block key via `v.Set(...)` after `AutomaticEnv` @@ -247,6 +279,7 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.migrations.enabled", "db.seed.enabled", "db.seed.sql_paths", + "auth.enabled", "edge_runtime.deno_version", "experimental.pgdelta.enabled", "experimental.pgdelta.declarative_schema_path", @@ -302,10 +335,11 @@ function applyRemoteOverride( if (blockSeed?.["enabled"] === undefined) { return { doc: deepMergeDoc(merged, { db: { seed: { enabled: false } } }), + appliedRemote: name, remoteOverrideKeys, }; } - return { doc: merged, remoteOverrideKeys }; + return { doc: merged, appliedRemote: name, remoteOverrideKeys }; } } return { doc, remoteOverrideKeys: new Set() }; @@ -336,23 +370,6 @@ function findDuplicateRemoteProjectId( return undefined; } -// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 -// lowercase ASCII letters. -const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). -// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key -// during config load (`config.go:898-903`), aborting before any db command when a -// name does not match. The source string is reused verbatim in the error message via -// `.source` so it byte-matches Go's `bucketNamePattern.String()`. -const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` -// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load -// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused -// in the message so it byte-matches Go's `funcSlugPattern.String()`. -const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; - /** * Go's `config.Validate` rejects any `[remotes.]` whose `project_id` is not a * valid project ref (`config.go:832-836`), on every config load — so a malformed or @@ -476,6 +493,18 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { return out.length === 0 ? "." : out.join("/"); } +/** + * Resolves a single seed `sql_paths` entry to Go's config-load form: a relative + * pattern is joined under `supabase/` (Go's `path.Join`, `config.go:918-921`); an + * absolute (or empty) pattern is returned verbatim. Used by the reader for + * `[db.seed].sql_paths` and by `db reset` for its `--sql-paths` override (Go's + * `resolveSeedSqlPaths`, `cmd/db.go`) so both feed the glob the same resolved paths. + */ +export const legacyResolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => + pattern.length === 0 || pathSvc.isAbsolute(pattern) + ? pattern + : legacyJoinSupabaseSeedPath(pattern); + /** `[db]` ports default through the development env unless `SUPABASE_ENV` overrides. */ const DEFAULT_SUPABASE_ENV = "development"; @@ -599,33 +628,6 @@ function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** Go's `json.Valid` (`encoding/json`): reports whether the string is well-formed JSON. */ -function legacyIsValidJson(value: string): boolean { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -} - -// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under -// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty -// string is `false`, and any other value is a parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly - * typed decode). Returns the bool, or `undefined` for a malformed value (which Go - * surfaces as a `failed to parse config` error). - */ -function legacyParseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; - return undefined; -} - /** * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. @@ -644,7 +646,12 @@ function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): bool // width). A TOML number (`enabled = 0`) is therefore an explicit false, NOT absent — it // must not fall through to the schema default. if (typeof value === "number") return value !== 0; - return fallback; + // Absent → the schema default. A PRESENT non-scalar (array/inline table, e.g. + // `enabled = []`) is a decode error in Go's `UnmarshalExact` during `LoadConfig`, so it + // must NOT fall through to the default — otherwise `db reset` could accept the prompt and + // drop schemas on a config Go rejects up front. + if (value === undefined) return fallback; + return "invalid"; } /** @@ -717,453 +724,141 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( } return Option.some(parsed); } - return Option.none(); + // Absent → `None` (Go's `*bool` stays nil). A present non-scalar value fails Go's + // `UnmarshalExact`, so reject it here rather than silently treating it as absent. + if (value === undefined) return Option.none(); + return yield* Effect.fail( + new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), + ); }); /** - * Recursively asserts every `encrypted:` secret in the (merged) config can be decrypted, - * mirroring Go's global `DecryptSecretHookFunc` (`pkg/config/secret.go:77-109`, - * `config.go:730`), which decrypts every `config.Secret` field during `UnmarshalExact` and - * aborts the load with `failed to parse config: ` when one cannot be decrypted (e.g. - * no `DOTENV_PRIVATE_KEY`). The reader's `[db.vault]` walk only covered vault secrets, so - * non-vault `Secret` fields (`db.root_key`, `auth.external.

.secret`, smtp `pass`, …) were - * silently passed through. A recursive string scan tracks Go's "decode the entire config" - * behaviour and stays robust as new `Secret` fields are added. The unset-`env(...)` and - * plain-string forms are returned verbatim by Go's hook (no error), so they are no-ops here. - * Returns the failure (or `undefined`); the caller surfaces it via `Effect.fail`. + * Dotted paths of every `config.Secret`-typed field Go decrypts via its global + * `DecryptSecretHookFunc` (`pkg/config/secret.go`, `config.go:730`) — the hook only runs + * while decoding INTO a `config.Secret`, never over arbitrary strings. `*` matches any map + * key (`auth.external.`, `auth.hook.`, `db.vault.`). `[db.vault]` + * (a `map[string]Secret`, `pkg/config/db.go:96`) IS included — the db-config reader below + * also decrypts it directly in its own body ({@link legacyReadDbToml}'s `vault` loop) with + * the same fail-on-undecryptable behaviour, so for that caller this just detects the same + * failure a little earlier (no observable difference: same `legacyDecryptSecret` call, same + * `failed to parse config: ` message). For `config push` — the other caller of + * {@link legacyAssertDecryptableSecrets}, which has no such downstream vault pass — omitting + * `db.vault` here would let an undecryptable vault secret through to the API calls, unlike Go. + * Derived from the Go structs (`auth.go`, `db.go`, `config.go`); update alongside any new + * `Secret` field. */ -const legacyAssertDecryptableSecrets = ( - value: unknown, - lookup: EnvLookup, - dotenvPrivateKeys: ReadonlyArray, -): LegacyDbConfigLoadError | undefined => { - if (typeof value === "string") { - const expanded = legacyExpandEnv(value, lookup); - if (ENV_PATTERN.test(expanded) || !legacyIsEncryptedSecret(expanded)) return undefined; - const decrypted = legacyDecryptSecret(expanded, dotenvPrivateKeys); - return decrypted.ok - ? undefined - : new LegacyDbConfigLoadError({ message: `failed to parse config: ${decrypted.error}` }); - } - if (Array.isArray(value)) { - for (const item of value) { - const error = legacyAssertDecryptableSecrets(item, lookup, dotenvPrivateKeys); - if (error !== undefined) return error; - } - return undefined; +const LEGACY_SECRET_PATHS: ReadonlyArray> = [ + ["db", "root_key"], + ["db", "vault", "*"], + ["auth", "publishable_key"], + ["auth", "secret_key"], + ["auth", "jwt_secret"], + ["auth", "anon_key"], + ["auth", "service_role_key"], + ["auth", "email", "smtp", "pass"], + ["auth", "external", "*", "secret"], + ["auth", "hook", "*", "secrets"], + ["auth", "sms", "twilio", "auth_token"], + ["auth", "sms", "twilio_verify", "auth_token"], + ["auth", "sms", "messagebird", "access_key"], + ["auth", "sms", "textlocal", "api_key"], + ["auth", "sms", "vonage", "api_secret"], + ["auth", "captcha", "secret"], + ["studio", "openai_api_key"], + // Go decodes `[edge_runtime.secrets]` as `SecretsConfig map[string]Secret` (config.go:283,287), + // so every value is decrypted by the hook — `*` spans the arbitrary secret names. + ["edge_runtime", "secrets", "*"], +]; + +/** Collects the string leaves reachable from `node` along `segs` (`*` spans map keys). */ +const legacyCollectSecretStrings = ( + node: unknown, + segs: ReadonlyArray, + index: number, + out: Array, +): void => { + if (index === segs.length) { + if (typeof node === "string") out.push(node); + return; } - const record = asRecord(value); - if (record !== undefined) { + const record = asRecord(node); + if (record === undefined) return; + const seg = segs[index]!; + if (seg === "*") { for (const key of Object.keys(record)) { - const error = legacyAssertDecryptableSecrets(record[key], lookup, dotenvPrivateKeys); - if (error !== undefined) return error; + legacyCollectSecretStrings(record[key], segs, index + 1, out); } + } else { + legacyCollectSecretStrings(record[seg], segs, index + 1, out); } - return undefined; }; -// Go merges the template default before Validate (`templates/config.toml`), so an absent -// `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). -const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). -const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; -// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). -const LEGACY_CLERK_DOMAIN_PATTERN = - /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; +/** Returns Go's hook-error message when a single `encrypted:` secret value cannot be decrypted. */ +const legacyAssertSecretValue = ( + value: string, + lookup: EnvLookup, + dotenvPrivateKeys: ReadonlyArray, +): string | undefined => { + const expanded = legacyExpandEnv(value, lookup); + // Unset `env(...)` and plain strings are returned verbatim by Go's hook (no error). + if (ENV_PATTERN.test(expanded) || !legacyIsEncryptedSecret(expanded)) return undefined; + const decrypted = legacyDecryptSecret(expanded, dotenvPrivateKeys); + return decrypted.ok ? undefined : `failed to parse config: ${decrypted.error}`; +}; /** - * Ports the FATAL validations Go runs inside `if c.Auth.Enabled { … }` during - * `config.Validate` (`apps/cli-go/pkg/config/config.go:1036-1102` + the nested - * `.validate()` methods at 1242-1632), in Go's first-failure-wins order, so a db/migration - * command aborts on an invalid auth config exactly like the Go CLI (the reviewer's case: - * `migration down --local` must stop before any destructive work). Every check below mirrors - * a Go `return errors.New(...)` site with the byte-exact message. + * Asserts every `config.Secret`-typed `encrypted:` value in the (merged) config can be + * decrypted, mirroring Go's global `DecryptSecretHookFunc`, which aborts the load with + * `failed to parse config: ` when a secret cannot be decrypted. Only the actual + * `Secret` field paths ({@link LEGACY_SECRET_PATHS}) are scanned — a non-secret string that + * merely starts with `encrypted:` (e.g. an auth email-template `subject`) stays plain text + * in Go and must not block the load. Go decodes every `[remotes.]` block into the same + * struct, so the same paths are checked under each remote too. Returns Go's error message (or + * `undefined`); callers surface it as their own domain error (e.g. `Effect.fail`). * - * Deliberately NOT ported: the `assertEnvLoaded` WARN lines (Go's `config.go:1143-1148` only - * prints a stderr warning and always returns nil — never fatal), and the non-fatal mutations - * (the SMS "no provider → disable phone login" WARN, the linkedin/slack deprecation WARN); - * those affect neither the exit code nor any value this subset reader exposes. The - * linkedin/slack providers are still skipped (Go deletes them before validating). + * Shared across command families: the db-config reader below uses it for `db push`/`db + * reset`/`migration up|down`/etc, and `config push`'s handler reuses it directly against its + * own (`@supabase/config`-decoded) document — both need the exact same "decrypt-or-abort before + * anything else runs" behaviour Go gets for free from `config.Load`. + * + * The "check every `[remotes.]` block too" part of that contract only holds when `doc` + * still has an intact `remotes` key. The db-config reader's own remote-merge keeps it (so this + * function really does check every declared remote there), but `@supabase/config`'s + * `loadProjectConfig` strips `remotes` from the document once a block matches the target ref — + * so for `config push`, an undecryptable secret hiding in a *different, non-matching* remote + * block goes unchecked (see that command's SIDE_EFFECTS.md KNOWN GAPS). */ -const legacyValidateAuthConfig = Effect.fnUntraced(function* ( - authRaw: RawDoc, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, +export const legacyAssertDecryptableSecrets = ( + doc: unknown, lookup: EnvLookup, -) { - const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); - const supabaseDir = path.join(workdir, "supabase"); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` - // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; - }; - // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. - // A malformed string ABORTS the load like Go's decode (it does NOT coerce to false), using - // the reader's `failed to parse config: invalid .` shape (the same simplification the - // db.* bools use; Go's verbose mapstructure string is not reproduced byte-for-byte there - // either). Absent / non-string → false (the default for every auth enable-flag). - const gate = (rec: RawDoc | undefined, key: string, field: string) => - Effect.gen(function* () { - const value = rec?.[key]; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return false; - const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); - // Resolve a config file path: absolute → verbatim (Go opens it from the OS root after chdir); - // relative → joined under `base` (`filepath.IsAbs` guards, `config.go:854-878`). - const resolvePath = (p: string, base: string): string => - path.isAbsolute(p) ? p : path.join(base, p); - - // A1: site_url required (`config.go:1037-1039`). - const siteUrl = - authRaw["site_url"] === undefined ? DEFAULT_AUTH_SITE_URL : str(authRaw, "site_url"); - if (siteUrl.length === 0) return yield* fail("Missing required field in config: auth.site_url"); - - // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider.UnmarshalText`, - // `auth.go:58-71`) that fires whenever `provider` is set, regardless of `enabled`; the - // required-field checks run only when enabled (`config.go:1048-1058`). - const captcha = asRecord(authRaw["captcha"]); - if (captcha !== undefined) { - const provider = str(captcha, "provider"); - if (provider.length > 0 && provider !== "hcaptcha" && provider !== "turnstile") - return yield* fail( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", - ); - if (yield* gate(captcha, "enabled", "auth.captcha.enabled")) { - if (provider.length === 0) - return yield* fail("Missing required field in config: auth.captcha.provider"); - if (str(captcha, "secret").length === 0) - return yield* fail("Missing required field in config: auth.captcha.secret"); - } - } - - // A5: signing keys file load (`config.go:1059-1065`) — read + parse as a JSON array. A - // relative path resolves under the supabase dir (`config.go:877-878`); absolute is verbatim. - const signingKeysPath = str(authRaw, "signing_keys_path"); - if (signingKeysPath.length > 0) { - const keysJson = yield* fs.readFileString(resolvePath(signingKeysPath, supabaseDir)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `failed to read signing keys: ${cause.message}`, - }), - ), - ); - yield* Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(keysJson); - if (!Array.isArray(parsed)) throw new Error("signing keys must be a JSON array of JWKs"); - return parsed; - }, - catch: (cause) => - new LegacyDbConfigLoadError({ - message: `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - } - - // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). - const passkey = asRecord(authRaw["passkey"]); - if (passkey !== undefined && (yield* gate(passkey, "enabled", "auth.passkey.enabled"))) { - const webauthn = asRecord(authRaw["webauthn"]); - if (webauthn === undefined) - return yield* fail( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - if (str(webauthn, "rp_id").length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_id"); - const rpOrigins = webauthn["rp_origins"]; - if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_origins"); - } - - // B1: hooks — each enabled hook (`config.go:1402-1470`). - const hook = asRecord(authRaw["hook"]); - if (hook !== undefined) { - const hookTypes = [ - "mfa_verification_attempt", - "password_verification_attempt", - "custom_access_token", - "send_sms", - "send_email", - "before_user_created", - ] as const; - for (const hookType of hookTypes) { - const h = asRecord(hook[hookType]); - if (h === undefined) continue; - if (!(yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) continue; - const uri = str(h, "uri"); - if (uri.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.uri`); - // Go uses net/url.Parse, which (unlike `new URL`) does not throw on a missing scheme; - // extract the scheme the same lenient way so a no-scheme/other URI hits the default - // branch rather than erroring (the rare url.Parse error case is not separately ported). - const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(uri)?.[1] ?? "").toLowerCase(); - const secrets = str(h, "secrets"); - if (scheme === "http" || scheme === "https") { - if (secrets.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.secrets`); - for (const secret of secrets.split("|")) { - if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } - } else if (scheme === "pg-functions") { - if (secrets.length > 0) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, - ); - } else { - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, - ); - } - } - } - - // B2: mfa — enroll requires verify (`config.go:1472-1483`). - const mfa = asRecord(authRaw["mfa"]); - if (mfa !== undefined) { - for (const [key, label] of [ - ["totp", "totp"], - ["phone", "phone"], - ["web_authn", "web_authn"], - ] as const) { - const factor = asRecord(mfa[key]); - if (factor === undefined) continue; - const enroll = yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`); - const verify = yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`); - if (enroll && !verify) - return yield* fail( - `Invalid MFA config: auth.mfa.${label}.enroll_enabled requires verify_enabled`, - ); - } - } - - // B3: email (`config.go:1242-1295`). - const email = asRecord(authRaw["email"]); - if (email !== undefined) { - // Go resolves a relative `content_path` differently per section: email TEMPLATE paths are - // relative to the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), while - // NOTIFICATION paths are relative to the supabase dir (`config.go:861-862`); absolute → as-is. - const validateTemplate = ( - section: "template" | "notification", - name: string, - tmpl: RawDoc, - base: string, - ) => - Effect.gen(function* () { - const contentPath = str(tmpl, "content_path"); - if (contentPath.length === 0) { - if (tmpl["content"] !== undefined) - return yield* fail( - `Invalid config for auth.email.${section}.${name}.content: please use content_path instead`, - ); - return; - } - yield* fs.readFileString(resolvePath(contentPath, base)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `Invalid config for auth.email.${section}.${name}.content_path: ${cause.message}`, - }), - ), - ); - }); - const templates = asRecord(email["template"]); - if (templates !== undefined) { - for (const name of Object.keys(templates)) { - const tmpl = asRecord(templates[name]); - if (tmpl !== undefined) yield* validateTemplate("template", name, tmpl, workdir); - } - } - const notifications = asRecord(email["notification"]); - if (notifications !== undefined) { - for (const name of Object.keys(notifications)) { - const tmpl = asRecord(notifications[name]); - if ( - tmpl !== undefined && - (yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) - ) - yield* validateTemplate("notification", name, tmpl, supabaseDir); + dotenvPrivateKeys: ReadonlyArray, +): string | undefined => { + const scan = (node: unknown): string | undefined => { + for (const segs of LEGACY_SECRET_PATHS) { + const values: Array = []; + legacyCollectSecretStrings(node, segs, 0, values); + for (const value of values) { + const error = legacyAssertSecretValue(value, lookup, dotenvPrivateKeys); + if (error !== undefined) return error; } } - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present - // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly - // disabled. - const smtp = asRecord(email["smtp"]); - const smtpEnabled = - smtp !== undefined && - (smtp["enabled"] === undefined - ? true - : yield* gate(smtp, "enabled", "auth.email.smtp.enabled")); - if (smtp !== undefined && smtpEnabled) { - if (str(smtp, "host").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.host"); - const portRaw = smtp["port"]; - const port = - typeof portRaw === "number" - ? portRaw - : typeof portRaw === "string" - ? Number(legacyExpandEnv(portRaw, lookup)) - : 0; - if (!port) return yield* fail("Missing required field in config: auth.email.smtp.port"); - if (str(smtp, "user").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.user"); - if (str(smtp, "pass").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.pass"); - if (str(smtp, "admin_email").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.admin_email"); - } - } - - // B4: sms — only the FIRST enabled provider is validated (Go's switch, `config.go:1297-1364`). - const sms = asRecord(authRaw["sms"]); - if (sms !== undefined) { - const twilio = asRecord(sms["twilio"]); - const twilioVerify = asRecord(sms["twilio_verify"]); - const messagebird = asRecord(sms["messagebird"]); - const textlocal = asRecord(sms["textlocal"]); - const vonage = asRecord(sms["vonage"]); - // Resolve every provider's enable-flag (a malformed bool aborts like Go's decode); Go's - // switch then validates only the FIRST enabled provider. - const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); - const twilioVerifyEnabled = yield* gate( - twilioVerify, - "enabled", - "auth.sms.twilio_verify.enabled", - ); - const messagebirdEnabled = yield* gate(messagebird, "enabled", "auth.sms.messagebird.enabled"); - const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); - const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); - if (twilioEnabled) { - if (str(twilio, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); - if (str(twilio, "message_service_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.message_service_sid"); - if (str(twilio, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); - } else if (twilioVerifyEnabled) { - if (str(twilioVerify, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.account_sid"); - if (str(twilioVerify, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ); - if (str(twilioVerify, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); - } else if (messagebirdEnabled) { - if (str(messagebird, "originator").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); - if (str(messagebird, "access_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); - } else if (textlocalEnabled) { - if (str(textlocal, "sender").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); - if (str(textlocal, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); - } else if (vonageEnabled) { - if (str(vonage, "from").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.from"); - if (str(vonage, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); - if (str(vonage, "api_secret").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); - } - } - - // B5: external providers (`config.go:1368-1398`). linkedin/slack are deprecated and deleted - // before validation, so they are never validated here. - const external = asRecord(authRaw["external"]); - if (external !== undefined) { - for (const name of Object.keys(external)) { - if (name === "linkedin" || name === "slack") continue; - const provider = asRecord(external[name]); - if (provider === undefined) continue; - if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; - if (str(provider, "client_id").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); - if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.secret`); + return undefined; + }; + const topLevel = scan(doc); + if (topLevel !== undefined) return topLevel; + const remotes = asRecord(asRecord(doc)?.["remotes"]); + if (remotes !== undefined) { + for (const name of Object.keys(remotes)) { + const error = scan(remotes[name]); + if (error !== undefined) return error; } } + return undefined; +}; - // B6: third_party — validate each enabled provider in order, then mutual exclusivity - // (`config.go:1584-1632`). Note `aws_cognito`'s messages say `cognito` (Go's wording). - const thirdParty = asRecord(authRaw["third_party"]); - if (thirdParty !== undefined) { - let enabledCount = 0; - const firebase = asRecord(thirdParty["firebase"]); - if ( - firebase !== undefined && - (yield* gate(firebase, "enabled", "auth.third_party.firebase.enabled")) - ) { - enabledCount += 1; - if (str(firebase, "project_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - } - const auth0 = asRecord(thirdParty["auth0"]); - if (auth0 !== undefined && (yield* gate(auth0, "enabled", "auth.third_party.auth0.enabled"))) { - enabledCount += 1; - if (str(auth0, "tenant").length === 0) - return yield* fail( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); - } - const cognito = asRecord(thirdParty["aws_cognito"]); - if ( - cognito !== undefined && - (yield* gate(cognito, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { - enabledCount += 1; - if (str(cognito, "user_pool_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - if (str(cognito, "user_pool_region").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - } - const clerk = asRecord(thirdParty["clerk"]); - if (clerk !== undefined && (yield* gate(clerk, "enabled", "auth.third_party.clerk.enabled"))) { - enabledCount += 1; - const domain = str(clerk, "domain"); - if (domain.length === 0) - return yield* fail( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) - return yield* fail( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", - ); - } - const workos = asRecord(thirdParty["workos"]); - if ( - workos !== undefined && - (yield* gate(workos, "enabled", "auth.third_party.workos.enabled")) - ) { - enabledCount += 1; - if (str(workos, "issuer_url").length === 0) - return yield* fail( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); - } - if (enabledCount > 1) - return yield* fail( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - } -}); +// Go merges the template default before Validate (`templates/config.toml`), so an absent +// `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). +const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; /** * Reads `/supabase/config.toml` (db subtree + project id) and the linked @@ -1173,7 +868,7 @@ const legacyValidateAuthConfig = Effect.fnUntraced(function* ( * Fails with `LegacyDbConfigLoadError` only when the config file is present but * unparseable; an absent file (and an absent/empty pooler-url file) is not an error. */ -export const legacyReadDbToml = Effect.fnUntraced(function* ( +const readDbTomlCore = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, @@ -1183,6 +878,12 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( // `--local` / `--db-url` / declarative pass nothing and read the unmerged config, // matching Go (those paths never resolve a ref before config load). ref?: string, + // Internal: when true the on-disk `config.toml` is treated as absent so the body + // resolves pure defaults (still honoring `SUPABASE_*` env overrides, which Go binds + // regardless of a config file). The lenient `legacyReadDbToml({ validate: false })` + // wrapper uses this as its fallback after a config-load failure, mirroring the + // best-effort behavior the container-id seam relied on before. + ignoreConfigFile = false, ) { const supabaseDir = path.join(workdir, "supabase"); const configPath = path.join(supabaseDir, "config.toml"); @@ -1192,18 +893,20 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( // is swallowed, every other read error aborts rather than silently running against the // default local database. Effect surfaces "not found" as `PlatformError` with a // `SystemError` reason tagged `"NotFound"`. - const maybeContent = yield* fs.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) - : Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), - ), - ); + const maybeContent = ignoreConfigFile + ? Option.none() + : yield* fs.readFileString(configPath).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to read file config: ${error.message}`, + }), + ), + ), + ); // Resolve `env(VAR)` against the shell env first, then the project `.env` files // (Go's `loadNestedEnv` populates the process env before `LoadEnvHook`). Built @@ -1228,9 +931,16 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( let functionsRaw: RawDoc | undefined; let analyticsRaw: RawDoc | undefined; let projectId = Option.none(); + // Whether `config.toml` set a top-level `project_id` string that env-expanded to empty + // (`project_id = ""`). Go keeps that empty override and `config.Validate` fails with + // `Missing required field in config: project_id` (config.go:991); tracked here so the + // check can run after the `SUPABASE_PROJECT_ID` env override below may still rescue it. + let projectIdExplicitEmpty = false; // Config keys a matched remote block contributed at viper's override tier (Go's // `v.Set`), so they must beat the matching `SUPABASE_*` env overrides below. let remoteOverrideKeys: ReadonlySet = new Set(); + // The matched `[remotes.]` block name, echoed as Go's config-override line. + let appliedRemote: string | undefined; if (Option.isSome(maybeContent)) { let doc: RawDoc | undefined; try { @@ -1271,6 +981,7 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( : applyRemoteOverride(doc, ref, lookup); const effectiveDoc = remoteOverride.doc; remoteOverrideKeys = remoteOverride.remoteOverrideKeys; + appliedRemote = remoteOverride.appliedRemote; db = asRecord(effectiveDoc?.["db"]); experimentalRaw = asRecord(effectiveDoc?.["experimental"]); pgDeltaRaw = asRecord(experimentalRaw?.["pgdelta"]); @@ -1289,16 +1000,20 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( projectId = nonEmptyString( typeof rawProjectId === "string" ? legacyExpandEnv(rawProjectId, lookup) : rawProjectId, ); + // A present `project_id` string that resolves to empty is Go's "kept empty override". + projectIdExplicitEmpty = typeof rawProjectId === "string" && Option.isNone(projectId); // Go's `DecryptSecretHookFunc` is a global decode hook (config.go:730) that decrypts // EVERY `config.Secret` field during `UnmarshalExact`, so an `encrypted:` secret anywhere // in the merged config that cannot be decrypted (e.g. no DOTENV_PRIVATE_KEY) aborts the // load with `failed to parse config: ` (secret.go:34,103; config.go:704) — before - // Validate and before connecting. The reader otherwise only decrypts `[db.vault]`, so - // assert decryptability across the whole document to match Go (a recursive scan tracks - // Go's "decode the entire config" better than a hand-listed set of Secret paths). + // Validate and before connecting. This also covers `[db.vault]` (see + // `LEGACY_SECRET_PATHS`), so the vault loop below never actually reaches an + // undecryptable value — it just decrypts-and-populates the already-asserted-valid ones. const secretError = legacyAssertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys); - if (secretError !== undefined) return yield* Effect.fail(secretError); + if (secretError !== undefined) { + return yield* Effect.fail(new LegacyDbConfigLoadError({ message: secretError })); + } } // Go: `config.go:626` — read the linked pooler URL from `.temp/pooler-url` and @@ -1344,6 +1059,16 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( projectId = nonEmptyString(legacyExpandEnv(projectIdEnv, lookup)); } + // Go's `config.Validate` rejects an empty top-level `project_id` (config.go:991). An + // absent field is tolerated here (deferred), but a present `project_id = ""` that the + // `SUPABASE_PROJECT_ID` override did not rescue is a load error, so a destructive command + // (e.g. remote `db reset`) fails fast rather than dropping schemas on a config Go rejects. + if (projectIdExplicitEmpty && Option.isNone(projectId)) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ message: "Missing required field in config: project_id" }), + ); + } + // A present-but-unmarshalable port aborts in Go rather than defaulting; mirror // that so `test db --local` never silently targets the default local database // while hiding a broken `[db]` config. @@ -1407,22 +1132,10 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( }), ); } - // Reject unsupported major versions like Go's config.Validate ({13,14,15,17}; - // `apps/cli-go/pkg/config/config.go:869-897`) before any image/container runs. An - // absent value falls through to the default (Go's zero-then-default). - if ( - typeof majorVersionResolved === "number" && - ![13, 14, 15, 17].includes(majorVersionResolved) - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - majorVersionResolved === 12 - ? "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects." - : `Failed reading config: Invalid db.major_version: ${majorVersionResolved}.`, - }), - ); - } + // Rejecting an unsupported major version ({13,14,15,17}; `apps/cli-go/pkg/config/config.go: + // 869-897`) is now `legacyValidateResolvedConfig`'s `db.major_version` switch (called once, + // below) — an absent value falls through to the default (Go's zero-then-default) and a present + // one (including `0`) flows into `input.db.majorVersion` for that switch to check. const majorVersion = typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; @@ -1475,25 +1188,10 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( }), ); } - // Go's config.Validate rejects a present-but-invalid deno_version before pg-delta - // runs (`config.go:999-1008`): 0 → missing-required, anything other than 1/2 → - // invalid. An absent key falls through to the default (Go merges deno_version=2). - if (typeof denoVersionResolved === "number") { - if (denoVersionResolved === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: edge_runtime.deno_version", - }), - ); - } - if (denoVersionResolved !== 1 && denoVersionResolved !== 2) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${denoVersionResolved}.`, - }), - ); - } - } + // Rejecting a present-but-invalid deno_version (`config.go:999-1008`: 0 → missing-required, + // anything other than 1/2 → invalid) is now `legacyValidateResolvedConfig`'s + // `edgeRuntimeDenoVersion` switch (called once, below). An absent key falls through to the + // default (Go merges deno_version=2). const denoVersion = typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; @@ -1577,102 +1275,323 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), lookup, ); - // Go's config.Validate aborts config load when a non-empty format_options is not - // valid JSON (`apps/cli-go/pkg/config/config.go:1685-1686`), before any shadow / - // catalog container runs. Fail here with Go's exact message so the user gets the - // actionable error up front rather than a later `JSON.parse` failure in the script. - if (formatOptionsExpanded.length > 0 && !legacyIsValidJson(formatOptionsExpanded)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - }), - ); - } + // Rejecting a non-empty, non-JSON `format_options` (`apps/cli-go/pkg/config/config.go: + // 1685-1686`) is now `legacyValidateResolvedConfig`'s `experimental.pgdeltaFormatOptions` + // check (called once, below). const formatOptions = nonEmptyString(formatOptionsExpanded); - // Go's config.Validate runs `ValidateBucketName` over every `[storage.buckets.*]` - // key on load (`apps/cli-go/pkg/config/config.go:898-903`), rejecting the config - // before any db command when a bucket name does not match `bucketNamePattern`. - // The reader otherwise drops `storage.buckets`, so port the check here with Go's - // exact message (the trailing `(%s)` is the regex source, `config.go:1386`). + // Bucket-name/function-slug validation (`config.go:898-903`/`993-998`) now lives in + // `legacyValidateResolvedConfig` (called once, below); only the pure extraction stays here. const bucketsRaw = asRecord(storageRaw?.["buckets"]); - if (bucketsRaw !== undefined) { - for (const name of Object.keys(bucketsRaw)) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, - }), - ); - } - } - } - // Go's config.Validate runs `ValidateFunctionSlug` over every `[functions.*]` key on - // load (`apps/cli-go/pkg/config/config.go:993-998`, immediately after the bucket loop), - // rejecting the config before any db command when a slug does not match - // `funcSlugPattern`. The reader otherwise drops `functions`, so port the check here - // with Go's exact message (the trailing `(%s)` is the regex source, `config.go:1376`). - if (functionsRaw !== undefined) { - for (const name of Object.keys(functionsRaw)) { - if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, - }), - ); - } - } - } - - // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) - // after the bucket/function checks — port its fatal validations so db/migration commands - // abort on an invalid auth config exactly like Go (e.g. an enabled passkey without a valid - // [auth.webauthn], or two third_party providers). Gated on `auth.enabled` (default true). - // Go's viper AutomaticEnv binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate - // (`config.go:529-535`), so the env override decides whether the auth block is validated. + // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) after + // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv + // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate (`config.go:529-535`), so the + // env override decides whether the auth block is validated — UNLESS a matched `[remotes.*]` + // block supplies `auth.enabled` itself, in which case `mergeRemoteConfig`'s `v.Set` (override + // tier, above `AutomaticEnv`) wins, matching the same suppression every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` entry gets below. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], true, lookup, - envOverride("SUPABASE_AUTH_ENABLED"), + remoteOverrideKeys.has("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), ); + + // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity + // CHECKS now live in `legacyValidateResolvedConfig`; `str`/`gate`/`fail` are still needed here + // to build that call's `LegacyAuthInput`, and by the D-only sms/external checks below (never + // part of the shared validator — see `legacy-config-validate.ts`'s module header). + const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); + // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` + // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. + const str = (rec: RawDoc | undefined, key: string): string => { + const value = rec?.[key]; + return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; + }; + // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. A + // malformed string ABORTS the load like Go's decode (it does NOT coerce to false). Absent / + // non-string → false (the default for every auth enable-flag). + const gate = (rec: RawDoc | undefined, key: string, field: string) => + Effect.gen(function* () { + const value = rec?.[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return false; + const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); + if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); + return parsed; + }); + + const authRawResolved = authRaw ?? {}; + let authInput: LegacyAuthInput | undefined; if (authEnabled) { - yield* legacyValidateAuthConfig(authRaw ?? {}, fs, path, workdir, lookup); + // A1: site_url required (`config.go:1037-1039`). + const siteUrl = + authRawResolved["site_url"] === undefined + ? DEFAULT_AUTH_SITE_URL + : str(authRawResolved, "site_url"); + + // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider. + // UnmarshalText`, `auth.go:58-71`); the required-field checks are `enabled`-gated + // (`config.go:1048-1058`) — both now live in `legacyValidateResolvedConfig`. + const captchaRaw = asRecord(authRawResolved["captcha"]); + let captchaInput: LegacyCaptchaInput | undefined; + if (captchaRaw !== undefined) { + const provider = str(captchaRaw, "provider"); + const secret = str(captchaRaw, "secret"); + captchaInput = { + enabled: yield* gate(captchaRaw, "enabled", "auth.captcha.enabled"), + // `str()` returns `""` for an absent key, but the shared validator's + // `provider === undefined` check needs a real `undefined` to fire correctly for an + // enabled captcha with no provider set. + provider: provider.length > 0 ? provider : undefined, + secret: secret.length > 0 ? secret : undefined, + }; + } + + // A5: signing keys file load (`config.go:1059-1065`) — I/O, stays in D. A relative path + // resolves under the supabase dir; absolute is verbatim. + const signingKeysPath = str(authRawResolved, "signing_keys_path"); + if (signingKeysPath.length > 0) { + const keysJson = yield* fs + .readFileString(legacyResolveSigningKeysPath(workdir, signingKeysPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysReadErrorMessage(cause) }), + ), + ); + yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(keysJson); + if (!Array.isArray(parsed)) { + throw new Error("signing keys must be a JSON array of JWKs"); + } + return parsed; + }, + catch: (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), + }); + } + + // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). + const passkeyRaw = asRecord(authRawResolved["passkey"]); + let passkeyInput: LegacyPasskeyInput | undefined; + if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { + const webauthnRaw = asRecord(authRawResolved["webauthn"]); + const rpOrigins = webauthnRaw?.["rp_origins"]; + passkeyInput = { + webauthnPresent: webauthnRaw !== undefined, + rpId: str(webauthnRaw, "rp_id"), + rpOrigins: Array.isArray(rpOrigins) ? rpOrigins : undefined, + }; + } + + // B1: hooks — each enabled hook, Go's fixed iteration order (`config.go:1402-1470`). + const hookRaw = asRecord(authRawResolved["hook"]); + const hookTypes = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", + ] as const; + const hooks: Array = []; + for (const hookType of hookTypes) { + const h = asRecord(hookRaw?.[hookType]); + if (h !== undefined && (yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) { + hooks.push({ type: hookType, uri: str(h, "uri"), secrets: str(h, "secrets") }); + } + } + + // B2: mfa — enroll requires verify (`config.go:1472-1483`), fixed totp/phone/web_authn order. + const mfaRaw = asRecord(authRawResolved["mfa"]); + const mfa: Array = []; + for (const label of ["totp", "phone", "web_authn"] as const) { + const factor = asRecord(mfaRaw?.[label]); + mfa.push({ + label, + enrollEnabled: yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`), + verifyEnabled: yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`), + }); + } + + // B3: email (`config.go:1242-1295`) — template/notification content is I/O, stays in D. Go + // resolves a relative `content_path` differently per section: TEMPLATE paths are relative to + // the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), NOTIFICATION paths are + // relative to the supabase dir (`config.go:861-862`); absolute → as-is. + const emailRaw = asRecord(authRawResolved["email"]); + const templatesRaw = asRecord(emailRaw?.["template"]); + if (templatesRaw !== undefined) { + for (const name of Object.keys(templatesRaw)) { + const tmpl = asRecord(templatesRaw[name]); + if (tmpl === undefined) continue; + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("template", name, cause), + }), + ), + ); + } + } + const notificationsRaw = asRecord(emailRaw?.["notification"]); + if (notificationsRaw !== undefined) { + for (const name of Object.keys(notificationsRaw)) { + const tmpl = asRecord(notificationsRaw[name]); + if ( + tmpl === undefined || + !(yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) + ) { + continue; + } + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: supabaseDir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("notification", name, cause), + }), + ), + ); + } + } + // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present + // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly + // disabled. + const smtpRaw = asRecord(emailRaw?.["smtp"]); + let smtpInput: LegacySmtpInput | undefined; + if (smtpRaw !== undefined) { + const smtpPortRaw = smtpRaw["port"]; + // The shared validator's required-field check is `port === 0` (Go decodes `port` into a + // numeric type at the config-decode step, so it can never observe a non-numeric value here). + // D reads the raw TOML/env string directly, so a non-numeric `port` (or an unresolved + // `env(VAR)`) parses to `NaN` via `Number(...)` — normalize that to `0` so it still trips + // the "missing required field" check instead of silently passing config load. + const smtpPortNumeric = + typeof smtpPortRaw === "number" + ? smtpPortRaw + : typeof smtpPortRaw === "string" + ? Number(legacyExpandEnv(smtpPortRaw, lookup)) + : 0; + smtpInput = { + enabled: + smtpRaw["enabled"] === undefined + ? true + : yield* gate(smtpRaw, "enabled", "auth.email.smtp.enabled"), + host: str(smtpRaw, "host"), + port: Number.isNaN(smtpPortNumeric) ? 0 : smtpPortNumeric, + user: str(smtpRaw, "user"), + pass: str(smtpRaw, "pass"), + adminEmail: str(smtpRaw, "admin_email"), + }; + } + + // B6: third_party — each enabled provider, Go's fixed order (`config.go:1584-1632`). Note + // `aws_cognito`'s messages say `cognito` (Go's wording). + const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const thirdParty: Array = []; + const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); + if ( + firebaseRaw !== undefined && + (yield* gate(firebaseRaw, "enabled", "auth.third_party.firebase.enabled")) + ) { + thirdParty.push({ provider: "firebase", requiredField: str(firebaseRaw, "project_id") }); + } + const auth0Raw = asRecord(thirdPartyRaw?.["auth0"]); + if ( + auth0Raw !== undefined && + (yield* gate(auth0Raw, "enabled", "auth.third_party.auth0.enabled")) + ) { + thirdParty.push({ provider: "auth0", requiredField: str(auth0Raw, "tenant") }); + } + const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); + if ( + cognitoRaw !== undefined && + (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: str(cognitoRaw, "user_pool_id"), + cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + }); + } + const clerkRaw = asRecord(thirdPartyRaw?.["clerk"]); + if ( + clerkRaw !== undefined && + (yield* gate(clerkRaw, "enabled", "auth.third_party.clerk.enabled")) + ) { + thirdParty.push({ provider: "clerk", requiredField: str(clerkRaw, "domain") }); + } + const workosRaw = asRecord(thirdPartyRaw?.["workos"]); + if ( + workosRaw !== undefined && + (yield* gate(workosRaw, "enabled", "auth.third_party.workos.enabled")) + ) { + thirdParty.push({ provider: "workos", requiredField: str(workosRaw, "issuer_url") }); + } + + authInput = { + siteUrl, + captcha: captchaInput, + passkey: passkeyInput, + hooks, + mfa, + smtp: smtpInput, + thirdParty, + }; } // Go's config.Validate validates `[analytics]` after the auth block (`config.go:1123-1135`). - // Two fatal checks run on the db/migration path: - // 1. `LogflareBackend.UnmarshalText` (`config.go:60-66`) is a decode-time enum that rejects - // any `backend` other than `postgres`/`bigquery` — regardless of `enabled` (it fires - // during UnmarshalExact, like the captcha provider enum), so it gates here too. - // 2. When analytics is enabled with the BigQuery backend, the three GCP fields are required - // (`config.go:1124-1134`), in order, with byte-exact messages. - // Go merges the template defaults `enabled = true`, `backend = "postgres"` before Validate - // (`templates/config.toml:388-392`), so an absent `[analytics]` section is enabled+postgres and - // passes (an empty backend never equals `bigquery`, so the GCP block is skipped). viper - // AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a matched remote block makes those keys env-immune, - // same as every other `LEGACY_ENV_OVERRIDABLE_KEYS` field above. + // Computed here (after the auth block, before the single shared call) rather than pure-derived + // earlier: `analyticsEnabled` resolves through `resolveBoolOrFail`, which can itself FAIL on a + // malformed `SUPABASE_ANALYTICS_ENABLED`/`analytics.enabled` bool — positioning that failure + // point ahead of the auth block would report it before an auth-block error even for a config + // that's ALSO broken there, reversing Go's real order. Go merges the template defaults + // `enabled = true`, `backend = "postgres"` before Validate (`templates/config.toml:388-392`), so + // an absent `[analytics]` section is enabled+postgres and passes (an empty backend never equals + // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a + // matched remote block makes those keys env-immune, same as every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` field above. const analyticsString = (key: string, envName: string): string => { const fromEnv = remoteOverrideKeys.has(`analytics.${key}`) ? undefined : envOverride(envName); const raw = fromEnv ?? analyticsRaw?.[key]; return typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; }; const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - if ( - analyticsBackend.length > 0 && - analyticsBackend !== "postgres" && - analyticsBackend !== "bigquery" - ) { - // Mirror the captcha enum's mapstructure envelope (`%v` of the allowed `[]LogflareBackend`). - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", - }), - ); - } const analyticsEnabled = yield* resolveBoolOrFail( "analytics.enabled", analyticsRaw?.["enabled"], @@ -1682,32 +1601,128 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); - if (analyticsEnabled && analyticsBackend === "bigquery") { - // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays - // non-empty and passes the `len(...) == 0` check, exactly like Go. - if (analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_id", - }), + // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays + // non-empty and passes the shared validator's `length === 0` check, exactly like Go. + const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); + const gcpProjectNumber = analyticsString( + "gcp_project_number", + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + ); + const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own (db.port + // is checked earlier, above, and stays there — see the comment at that check) is deferred to + // this single call, in Go's exact relative order. D's sms/external checks (D-only, never part + // of the shared validator) run AFTER this call succeeds, still gated on `authEnabled` — see + // `legacy-config-validate.ts`'s module header for the accepted ordering tradeoff this + // introduces against third_party. + const dbInput: LegacyDbInput = { port, majorVersion }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, + gcpProjectId, + gcpProjectNumber, + gcpJwtPath, + }; + const experimentalInput: LegacyExperimentalInput = { + pgdeltaFormatOptions: formatOptionsExpanded, + }; + const validationInput: LegacyConfigValidationInput = { + db: dbInput, + storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], + functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + auth: authInput, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + yield* Effect.try({ + try: () => legacyValidateResolvedConfig(validationInput), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + if (authEnabled) { + // B4: sms — D-only (`config.go:1145-1147`/`1348-1417`, never part of the shared validator, + // see `legacy-config-validate.ts`'s module header). Only the FIRST enabled provider is + // validated (Go's switch). + const sms = asRecord(authRawResolved["sms"]); + if (sms !== undefined) { + const twilio = asRecord(sms["twilio"]); + const twilioVerify = asRecord(sms["twilio_verify"]); + const messagebird = asRecord(sms["messagebird"]); + const textlocal = asRecord(sms["textlocal"]); + const vonage = asRecord(sms["vonage"]); + const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); + const twilioVerifyEnabled = yield* gate( + twilioVerify, + "enabled", + "auth.sms.twilio_verify.enabled", ); - } - if ( - analyticsString("gcp_project_number", "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER").length === 0 - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_number", - }), + const messagebirdEnabled = yield* gate( + messagebird, + "enabled", + "auth.sms.messagebird.enabled", ); + const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); + const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); + if (twilioEnabled) { + if (str(twilio, "account_sid").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); + if (str(twilio, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio.message_service_sid", + ); + if (str(twilio, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); + } else if (twilioVerifyEnabled) { + if (str(twilioVerify, "account_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.account_sid", + ); + if (str(twilioVerify, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.message_service_sid", + ); + if (str(twilioVerify, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); + } else if (messagebirdEnabled) { + if (str(messagebird, "originator").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); + if (str(messagebird, "access_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); + } else if (textlocalEnabled) { + if (str(textlocal, "sender").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); + if (str(textlocal, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); + } else if (vonageEnabled) { + if (str(vonage, "from").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.from"); + if (str(vonage, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); + if (str(vonage, "api_secret").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); + } } - if (analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - }), - ); + + // B5: external providers — D-only (`config.go:1148-1150`/`1368-1398`, never part of the + // shared validator). linkedin/slack are deprecated and deleted before validation, so they are + // never validated here. + const external = asRecord(authRawResolved["external"]); + if (external !== undefined) { + for (const name of Object.keys(external)) { + if (name === "linkedin" || name === "slack") continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; + if (str(provider, "client_id").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); + if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.secret`); + } } } @@ -1770,13 +1785,9 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( : typeof rawSqlPaths === "string" ? splitGoSeedPaths(rawSqlPaths) : ["seed.sql"]; - const seedSqlPaths = sqlPathPatterns.map((pattern) => { - // Patterns are already env-expanded above (Go's LoadEnvHook runs before the split), so - // an absolute path is used verbatim and a relative one is supabase-prefixed via Go's - // `path.Join("supabase", pattern)` → `path.Clean` (collapses `.`/`..`). - if (pattern.length === 0 || path.isAbsolute(pattern)) return pattern; - return legacyJoinSupabaseSeedPath(pattern); - }); + // Patterns are already env-expanded above (Go's LoadEnvHook runs before the split); + // resolve each to Go's config-load form (absolute verbatim, relative supabase-joined). + const seedSqlPaths = sqlPathPatterns.map((pattern) => legacyResolveSeedSqlPath(path, pattern)); // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` // ciphertext. `resolved` mirrors Go's `len(SHA256) > 0` gate (Go sets SHA256 only @@ -1860,10 +1871,53 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( migrationsEnabled, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, + appliedRemote, }; return values; }); +/** + * Read + validate `config.toml` exactly like Go's `flags.LoadConfig` → `config.Load` + * → `Validate`: an absent file yields defaults, but a present config that is + * unreadable/malformed, references an undecryptable `encrypted:` secret, or fails any + * of Go's decode/Validate checks (remote refs, `db.port`, `db.major_version`, + * `edge_runtime.deno_version`, pgdelta gate, `format_options` JSON, bucket names, + * function slugs, auth, analytics) aborts with the matching Go error. Call this at the + * point Go fails fast — before asserting the stack is running, prompting, or any + * destructive work — so a broken config never runs against the default local database. + */ +export const legacyCheckDbToml = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + ref?: string, +) => readDbTomlCore(fs, path, workdir, ref, false); + +/** + * Read `config.toml`. Defaults to Go's validating behavior (identical to + * {@link legacyCheckDbToml}); pass `{ validate: false }` for a best-effort read that + * never throws on an invalid config — a config-load failure falls back to pure + * defaults (env overrides still applied), matching the tolerant behavior the + * container-id seam needs when it only wants `projectId` and the handler has already + * validated the config. The throwing default keeps every existing caller at Go parity. + */ +export const legacyReadDbToml = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + ref?: string, + opts?: { readonly validate?: boolean }, +) => + opts?.validate === false + ? readDbTomlCore(fs, path, workdir, ref, false).pipe( + // Fall back to the ignore-file defaults path (never re-reads the broken config) + // so a best-effort caller gets a well-formed defaults result instead of a throw. + Effect.catchTag("LegacyDbConfigLoadError", () => + readDbTomlCore(fs, path, workdir, ref, true), + ), + ) + : readDbTomlCore(fs, path, workdir, ref, false); + /** * The effective declarative schema directory: the configured * `declarative_schema_path` (already `supabase/`-prefixed when relative) or the diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 986c8a6075..493f1f8276 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -7,6 +7,7 @@ import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { legacyApplyProjectEnv, + legacyCheckDbToml, legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, @@ -46,6 +47,69 @@ const loadEnv = (workdir: string) => return yield* legacyLoadProjectEnv(fs, path, workdir); }).pipe(Effect.provide(BunServices.layer)); +describe("read (lenient) vs check (throws) split", () => { + const withServices = ( + dir: string, + run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* run(fs, path); + }).pipe(Effect.provide(BunServices.layer)); + + it.effect("legacyCheckDbToml throws on an undecryptable secret", () => { + const dir = withConfig('[db]\nroot_key = "encrypted:anything"\n'); + return withServices(dir, (fs, path) => legacyCheckDbToml(fs, path, dir)).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse config: missing private key", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "legacyReadDbToml({ validate: false }) tolerates the same secret, returning defaults", + () => { + const dir = withConfig('[db]\nroot_key = "encrypted:anything"\n'); + return withServices(dir, (fs, path) => + legacyReadDbToml(fs, path, dir, undefined, { validate: false }), + ).pipe( + Effect.tap((v) => + Effect.sync(() => { + // The broken config is swallowed → the ignore-file defaults path (no vault). + expect(v.vault).toEqual([]); + expect(v.port).toBeGreaterThan(0); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("legacyReadDbToml({ validate: false }) still returns a valid config's values", () => { + const dir = withConfig('project_id = "lenientproj"\n'); + return withServices(dir, (fs, path) => + legacyReadDbToml(fs, path, dir, undefined, { validate: false }), + ).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectId).toEqual(Option.some("lenientproj")); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); + describe("legacyReadDbToml", () => { it.effect("returns defaults when config.toml is absent", () => { const dir = withConfig(undefined); @@ -389,6 +453,61 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { + // Same v.Set-above-AutomaticEnv precedence as db.migrations.enabled / pgdelta.enabled + // (config.go:635-637), but for auth.enabled specifically (CLI-1878): a matched remote + // block's auth.enabled must win over SUPABASE_AUTH_ENABLED. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_AUTH_ENABLED"]; + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth]", + "enabled = false", + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline.authEnabled).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; + else process.env["SUPABASE_AUTH_ENABLED"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("SUPABASE_AUTH_ENABLED still wins when the remote block omits auth.enabled", () => { + // Control: the env override is suppressed only for keys the matched block explicitly + // set; a block that omits auth.enabled leaves the env override in force. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_AUTH_ENABLED"]; + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline.authEnabled).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; + else process.env["SUPABASE_AUTH_ENABLED"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("matches a remote block by a SUPABASE_REMOTES__PROJECT_ID env override", () => { // Viper AutomaticEnv supplies/overrides remotes.prod.project_id, so the block merges // even with no TOML project_id (here it lifts major_version 15 over the base default). @@ -1466,6 +1585,28 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + // Divergence #1 fix: this used to fall through to the generic + // "Failed reading config: Invalid db.major_version: 0." message (the same branch that + // catches an unsupported value like 16) — `legacyValidateResolvedConfig`'s dedicated `0` case + // now matches Go's `Missing required field in config: db.major_version`. + const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: db.major_version", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { const dir = withConfig(["[db]", "major_version = 12", ""].join("\n")); return read(dir).pipe( @@ -2190,6 +2331,102 @@ describe("legacyReadDbToml encrypted secret decryption (Go DecryptSecretHookFunc it.effect("treats an unset env() secret as a no-op (verbatim, like Go's hook)", () => expectLoads(["[db]", 'root_key = "env(SOME_UNSET_ROOT_KEY)"']), ); + it.effect("does NOT decrypt a non-secret string that starts with encrypted:", () => + // Go's hook only runs while decoding into `config.Secret`; a non-secret field like an + // email-template subject stays plain text, so `db push`/`reset`/`start` must not abort. + expectLoads([ + "[auth.email.template.invite]", + 'subject = "encrypted: your invite"', + "[db]", + 'root_key = "env(SOME_UNSET_ROOT_KEY)"', + ]), + ); + it.effect("fails on an undecryptable auth.captcha.secret (Secret-typed field)", () => + expectFails( + [ + "[auth.captcha]", + "enabled = false", + 'provider = "hcaptcha"', + 'secret = "encrypted:anything"', + ], + "failed to parse config: missing private key", + ), + ); + it.effect("fails on an undecryptable [edge_runtime.secrets] value (map[string]Secret)", () => + expectFails( + ["[edge_runtime.secrets]", 'MY_SECRET = "encrypted:anything"'], + "failed to parse config: missing private key", + ), + ); + it.effect("fails on an undecryptable Secret inside a [remotes.*] block", () => + // Go decodes every remote block into the same struct, so an undecryptable secret in any + // remote (matched or not) aborts the load. + expectFails( + [ + "[remotes.preview]", + 'project_id = "abcdefghijklmnopqrst"', + "[remotes.preview.db]", + 'root_key = "encrypted:anything"', + ], + "failed to parse config: missing private key", + ), + ); +}); + +describe("legacyReadDbToml non-scalar config booleans (Go UnmarshalExact parity)", () => { + // Go's `UnmarshalExact` fails to decode a bool field given an array/inline-table value, + // aborting `LoadConfig` before any destructive work. A present non-scalar must not fall + // through to the schema default (which would let `db reset` prompt + drop schemas). + const failsInvalid = (lines: ReadonlyArray, field: string) => + Effect.gen(function* () { + const dir = withConfig(lines.join("\n")); + const exit = yield* read(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(`failed to parse config: invalid ${field}.`); + } + rmSync(dir, { recursive: true, force: true }); + }); + it.effect("rejects an array value for [db.migrations] enabled", () => + failsInvalid(["[db.migrations]", "enabled = []"], "db.migrations.enabled"), + ); + it.effect("rejects an inline-table value for [db.seed] enabled", () => + failsInvalid(["[db.seed]", "enabled = {}"], "db.seed.enabled"), + ); +}); + +describe("legacyReadDbToml empty project_id (Go config.Validate parity)", () => { + it.effect("rejects a present-but-empty top-level project_id", () => { + // Go keeps the empty override and `config.Validate` fails "Missing required field in + // config: project_id" (config.go:991) before any destructive command runs. + const dir = withConfig('project_id = ""\n'); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("still tolerates an absent project_id (deferred broader requirement)", () => { + const dir = withConfig("[db]\nmajor_version = 15\n"); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.baseline).toBeDefined(); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); describe("legacyReadDbToml [analytics] validation (Go config.Validate parity)", () => { diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index bbf1e81dbf..d2bfb2b261 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -1,4 +1,5 @@ import { Context, type Effect, type Scope } from "effect"; +import type { LegacyConnectSuggestionContext } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbCopyError, @@ -71,6 +72,13 @@ export interface LegacyPgConnInput { * `ToPostgresURL`/`ConnectLocalPostgres`). */ readonly connectTimeoutSeconds?: number; + /** + * Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion`, + * which reads the ambient `CurrentProfile` in `ConnectByUrl`). The resolver attaches + * it so the driver layer can map a refused/auth/IPv6 connect error to Go's actionable + * hint. Absent → the driver omits the suggestion (callers fall back to the generic one). + */ + readonly suggestionContext?: LegacyConnectSuggestionContext; } /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index fbd5bfca90..f291140c54 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -10,6 +10,7 @@ import * as Reactivity from "effect/unstable/reactivity/Reactivity"; // resolves, so the COPY path and the pooled path use the same driver. import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; +import { legacyConnectSuggestion } from "./legacy-connect-errors.ts"; import { LegacyDbConnectError, LegacyDbCopyError, @@ -407,8 +408,20 @@ const connect = ( connectionTimeoutMillis: connectTimeoutSeconds * 1000, }); - const toConnectError = (error: unknown) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }); + // Go's `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure + // (`connect.go:187`), mapping the driver error to an actionable hint that replaces + // the generic "--debug" suggestion. The resolver attaches the profile context to + // `cfg.suggestionContext`; map it here so the suggestion travels on the error. + const toConnectError = (error: unknown) => { + const suggestion = + cfg.suggestionContext === undefined + ? undefined + : legacyConnectSuggestion(error, cfg.suggestionContext); + return new LegacyDbConnectError({ + message: `failed to connect to postgres: ${error}`, + ...(suggestion === undefined ? {} : { suggestion }), + }); + }; // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse // time; a missing/unreadable file aborts). Skipped for local connections, which @@ -548,8 +561,7 @@ const connect = ( const fresh = new Pg.Client(winningRawConfig); yield* Effect.tryPromise({ try: () => fresh.connect(), - catch: (error) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }), + catch: toConnectError, }); if (!isLocal && needsRoleStepDown(cfg.user)) { yield* Effect.tryPromise({ diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 2f47d3c84e..6eac57c3cd 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -45,10 +45,31 @@ export interface LegacyDbTargetSelection { * (`src/shared/legacy/global-flags.ts`, `src/shared/cli/global-flags.ts`). * `Flag.string` / `Flag.choice` / `Flag.integer` → value-consuming; * `Flag.boolean` → not. + * + * Also consulted by `extractChangedFlagNames` + * (`legacy/telemetry/legacy-command-instrumentation.ts`), which scans EVERY + * legacy command's raw argv, not just the db-target/global subset above — so + * this set additionally lists every other value-consuming (non-boolean) flag + * declared anywhere under `legacy/commands/`. Without an entry here, a bare + * `--some-local-flag ` is mis-scanned: the value token is treated as a + * separate flag rather than skipped, and if that token happens to look like a + * global flag's long name (e.g. `secrets set --env-file --debug`, where + * `--debug` is `--env-file`'s value under pflag semantics), CLI-1896's + * global-flag fallback fabricates a `flags.debug` value Go never records for + * that invocation. `legacy-db-target-flags.unit.test.ts` statically scans + * every `*.command.ts` file's directly-declared `Flag.string`/`Flag.integer`/ + * `Flag.choice`/`Flag.choiceWithValue` calls and asserts they're all + * represented here, so a new command that adds a value-consuming flag and + * forgets to register it fails CI. That scan cannot see flag names built + * through a helper indirection (`issue.command.ts`'s + * `legacyIssueOptionalTextFlag`, `status.command.ts`'s `csvStringSliceFlag`) + * — those flags are listed below by hand and excluded from the scan. */ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags "db-url", + "password", // db push/pull/dump/remote (StringVarP, short -p) + "sql-paths", "schema", "level", "fail-on", @@ -70,6 +91,74 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "network-id", "dns-resolver", "agent", + // Every other value-consuming flag declared directly across legacy/commands/ + // (CLI-1896 review follow-up — see the doc comment above). + "add-domains", + "algorithm", + "attribute-mapping-file", + "auth", + "config", + "custom-hostname", + "db-allow-cidr", + "db-password", + "db-unban-ip", + "desired-subdomain", + "diff-engine", + "domains", + "env-file", + "exclude", + "exp", + "file", + "from", + "from-backup", + "git-branch", + "import-map", + "inspect-mode", + "lang", + "last", + "metadata-file", + "metadata-url", + "name", + "name-id-format", + "notify-url", + "org-id", + "override-name", + "payload", + "plan", + "project-id", + "project-ref", + "query-timeout", + "region", + "remove-domains", + "role", + "size", + "status", + "sub", + "swift-access-control", + "template", + "timestamp", + "to", + "token", + "valid-for", + "version", + // Declared through a name-parameterized helper, invisible to the static + // scan (see the doc comment above): `issue.command.ts`'s + // `legacyIssueOptionalTextFlag` and `status.command.ts`'s + // `csvStringSliceFlag`. + "additional-context", + "area", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "crash-report-id", + "docker-services", + "problem", + "proposed-solution", + "alternatives", + "link", + "issue-type", + "improvement", ]); /** @@ -79,8 +168,11 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "s", // --schema / -s "o", // --output / -o - "p", // --password / -p + "p", // --password / -p (migration list, db push/pull/dump/remote) "j", // --jobs / -j (storage cp) + "f", // --file / -f (db dump/diff/query, db schema declarative sync) + "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) + "x", // --exclude / -x (start, db dump) ]); /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts index 2b32ca5ca5..9799b0a636 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts @@ -1,5 +1,12 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { resolveLegacyDbTargetFlags } from "./legacy-db-target-flags.ts"; +import { + resolveLegacyDbTargetFlags, + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "./legacy-db-target-flags.ts"; describe("resolveLegacyDbTargetFlags", () => { it("returns empty setFlags and undefined connType when no args", () => { @@ -185,3 +192,101 @@ describe("resolveLegacyDbTargetFlags", () => { expect(result.setFlags).toEqual(["local"]); }); }); + +describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { + // `legacy/telemetry/legacy-command-instrumentation.ts`'s `extractChangedFlagNames` + // relies on these two sets to know which flag consumes the next raw-argv + // token as its value, across EVERY legacy command (not just the db-target + // subset this file's other describe block covers) — see the doc comment on + // `VALUE_CONSUMING_LONG_FLAGS` in `legacy-db-target-flags.ts`. This scan is + // static-source-based (same technique as + // `shared/cli/code-structure.unit.test.ts`) rather than importing every + // command module, so it can only see flag names declared as a literal + // string argument to `Flag.string`/`Flag.integer`/`Flag.choice`/ + // `Flag.choiceWithValue`/`Flag.float` — it cannot trace a name passed + // through a helper function (`issue.command.ts`'s + // `legacyIssueOptionalTextFlag`, `status.command.ts`'s + // `csvStringSliceFlag`), so those two files are excluded below; their flag + // names are registered by hand in `VALUE_CONSUMING_LONG_FLAGS` instead. + const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); + const INDIRECT_NAME_FILES = new Set(["issue.command.ts", "status.command.ts"]); + const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; + + function walk(dir: string): Array { + return readdirSync(dir).flatMap((entry) => { + const fullPath = path.join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) return walk(fullPath); + return entry.endsWith(".command.ts") ? [fullPath] : []; + }); + } + + interface DeclaredFlag { + readonly file: string; + readonly name: string; + readonly alias: string | undefined; + } + + function extractDeclaredFlags(filePath: string): Array { + const source = readFileSync(filePath, "utf8"); + const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; + const calls = Array.from(source.matchAll(callRegex), (match) => ({ + index: match.index, + kind: match[1]!, + })); + + const declared: Array = []; + for (let i = 0; i < calls.length; i++) { + const current = calls[i]!; + if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; + + // Name declared as a literal string (e.g. `Flag.string("schema")`). + // A name passed as an identifier (`Flag.string(name)`) doesn't match + // and is silently skipped — see INDIRECT_NAME_FILES above. + const remainder = source.slice(current.index); + const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); + if (!nameMatch) continue; + + // The alias, if any, is somewhere in the `.pipe(...)` chain between + // this flag declaration and the next one. + const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; + const window = source.slice(current.index, windowEnd); + const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); + + declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); + } + return declared; + } + + it("registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", () => { + const missing: Array = []; + + for (const filePath of walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath)) { + if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { + missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); + } + } + } + + expect(missing).toEqual([]); + }); + + it("registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", () => { + const missing: Array = []; + + for (const filePath of walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath)) { + if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { + missing.push(`-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`); + } + } + } + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 41a5e74b14..93ac55723b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -32,15 +32,30 @@ function truncateText(text: string, maxLength: number) { return text.length > maxLength ? text.slice(0, maxLength) : text; } -/** Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading - * `_.-`, and cap at 40 chars. */ -function sanitizeProjectId(src: string) { +/** + * Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading + * `_.-`, and cap at 40 chars. + * + * Exported because it is not only a container-*naming* concern: Go's + * `Config.Validate` (`pkg/config/config.go:938-944`) rewrites `c.ProjectId` + * to this same sanitized form **in place, once, at config-load time** (every + * `flags.LoadConfig` call ends in `Load` -> `Validate`), and every later use + * of `Config.ProjectId` — including the Docker LABEL value written by `start` + * (`internal/utils/docker.go:375`: `config.Labels[CliProjectLabel] = + * Config.ProjectId`) — reads that already-sanitized singleton. `GetId` itself + * performs no sanitisation of its own; it just reads the pre-sanitized value. + * So on the config/env-derived (non-`--project-id`) path, callers building a + * Docker label FILTER must sanitize too, or a `project_id` like `"my app"` + * filters on the raw string while `start` labeled the sanitized one and never + * matches anything (see `legacyCliProjectFilterValue`'s doc comment). + */ +export function legacySanitizeProjectId(src: string) { const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); } function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${sanitizeProjectId(projectId)}`; + return `supabase_${name}_${legacySanitizeProjectId(projectId)}`; } /** `utils.DbId` — the local Postgres container name. */ @@ -52,3 +67,57 @@ export function localDbContainerId(projectId: string) { export function localNetworkId(projectId: string) { return localDockerId("network", projectId); } + +/** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the + * Docker label every container/volume/network created by `supabase start` carries. */ +export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; + +/** + * Go's `utils.GetDockerIds()` (`apps/cli-go/internal/utils/config.go:82-98`) — the + * 13 service container ids (excludes `db`, `network`, and the `differ` shadow + * container, which are not part of the "expected running services" set). Order and + * alias-name strings are taken verbatim from `config.go:36-49,61-79`. + */ +export function legacyServiceContainerIds(projectId: string): ReadonlyArray { + return [ + localDockerId("kong", projectId), + localDockerId("auth", projectId), + localDockerId("inbucket", projectId), + localDockerId("realtime", projectId), + localDockerId("rest", projectId), + localDockerId("storage", projectId), + localDockerId("imgproxy", projectId), + localDockerId("pg_meta", projectId), + localDockerId("studio", projectId), + localDockerId("edge_runtime", projectId), + localDockerId("analytics", projectId), + localDockerId("vector", projectId), + localDockerId("pooler", projectId), + ]; +} + +/** + * Go's `utils.CliProjectFilter` (`apps/cli-go/internal/utils/docker.go:148-156`) — + * the value that follows `--filter label=` on the `docker`/`podman` CLI. An empty + * `projectId` (Go's `--all` path) filters on the bare label across every project. + * + * This function itself does not sanitize — by design, it's a pure pass-through. + * The caller is responsible for sanitizing `projectId` with + * {@link legacySanitizeProjectId} on the config/env-derived (default) path + * BEFORE calling this, matching Go's `Config.Validate` sanitizing the + * `Config.ProjectId` singleton once at config-load time so every later + * reader — including the Docker LABEL `start` writes — sees the same + * sanitized string. An explicit `--project-id ` (where one exists, + * e.g. `stop`) is Go's one exception: it assigns straight to + * `Config.ProjectId` without going through `Validate` + * (`apps/cli-go/internal/stop/stop.go:19-20`), so that path must stay raw/ + * unsanitized to match. There is also no injection risk either way: this + * value is always passed as a single argv element to a spawned process + * (never through a shell), so a malformed value can only make Docker's own + * filter parsing reject it or match nothing — it cannot break out into + * another command. + */ +export function legacyCliProjectFilterValue(projectId: string): string { + if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; + return `${LEGACY_CLI_PROJECT_LABEL}=${projectId}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ff967f18b8..9c07805652 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { legacyResolveLocalProjectId, localDbContainerId } from "./legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -23,3 +30,72 @@ describe("legacyResolveLocalProjectId", () => { expect(localDbContainerId(id)).toBe("supabase_db_env-id"); }); }); + +describe("legacyServiceContainerIds", () => { + it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + // apps/cli-go/internal/utils/config.go:82-98 — kong, auth, inbucket, realtime, + // rest, storage, imgproxy, pg_meta, studio, edge_runtime, analytics, vector, pooler. + expect(legacyServiceContainerIds("my-app")).toEqual([ + "supabase_kong_my-app", + "supabase_auth_my-app", + "supabase_inbucket_my-app", + "supabase_realtime_my-app", + "supabase_rest_my-app", + "supabase_storage_my-app", + "supabase_imgproxy_my-app", + "supabase_pg_meta_my-app", + "supabase_studio_my-app", + "supabase_edge_runtime_my-app", + "supabase_analytics_my-app", + "supabase_vector_my-app", + "supabase_pooler_my-app", + ]); + }); + + it("sanitizes the project id the same way as localDbContainerId", () => { + const ids = legacyServiceContainerIds("My App!!"); + expect(ids[0]).toBe("supabase_kong_My_App_"); + }); +}); + +describe("legacyCliProjectFilterValue", () => { + it("returns the bare label when the project id is empty (Go's --all path)", () => { + expect(legacyCliProjectFilterValue("")).toBe(LEGACY_CLI_PROJECT_LABEL); + }); + + it("returns label=projectId when a project id is given", () => { + expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); + }); + + it("must be sanitized by the caller for the label to match what start wrote", () => { + // This function is a pure pass-through by design (see its doc comment) — a + // dirty config/env-derived id must be sanitized by the caller BEFORE being + // passed here, matching Go's Config.Validate sanitizing Config.ProjectId + // once at config-load time so every reader (including the Docker label + // `start` writes) sees the same string. + const dirty = "My App!!"; + expect(legacyCliProjectFilterValue(dirty)).toBe(`${LEGACY_CLI_PROJECT_LABEL}=My App!!`); + expect(legacyCliProjectFilterValue(legacySanitizeProjectId(dirty))).toBe( + `${LEGACY_CLI_PROJECT_LABEL}=My_App_`, + ); + }); +}); + +describe("legacySanitizeProjectId", () => { + it("replaces invalid character runs with a single underscore", () => { + expect(legacySanitizeProjectId("My App!!")).toBe("My_App_"); + }); + + it("strips leading underscore/dot/dash runs", () => { + expect(legacySanitizeProjectId("...hidden-app")).toBe("hidden-app"); + }); + + it("caps the result at 40 characters", () => { + const long = "a".repeat(50); + expect(legacySanitizeProjectId(long)).toBe("a".repeat(40)); + }); + + it("leaves an already-clean id unchanged", () => { + expect(legacySanitizeProjectId("my-app_123")).toBe("my-app_123"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts new file mode 100644 index 0000000000..f8884a700a --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -0,0 +1,259 @@ +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Listing containers or volumes by Docker label failed. Wraps Go's + * `Docker.ContainerList`/`Docker.VolumeList` errors (`docker.go:99-104`, + * `docker.go:334-336` — see `checkServiceHealth`/`DockerRemoveAll`), which Go + * wraps as `"failed to list containers: %w"` / equivalent. + */ +export class LegacyDockerLifecycleListError extends Data.TaggedError( + "LegacyDockerLifecycleListError", +)<{ + readonly message: string; +}> {} + +/** Inspecting a single container's state failed for a reason other than "not found". */ +export class LegacyDockerLifecycleInspectError extends Data.TaggedError( + "LegacyDockerLifecycleInspectError", +)<{ + readonly message: string; +}> {} + +function collectByteStream(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +function splitNonEmptyLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** + * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` + * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter + * label=`. `all: false` mirrors `status`'s running-only list; + * `all: true` mirrors `stop`'s "every container regardless of state" list. + */ +export const legacyListContainersByLabel = ( + spawner: Spawner, + opts: { + readonly projectIdFilter: string; + readonly all: boolean; + readonly format: "id" | "names"; + }, +) => + Effect.scoped( + Effect.gen(function* () { + const formatArg = opts.format === "names" ? "{{.Names}}" : "{{.ID}}"; + const args = [ + "ps", + "--filter", + `label=${opts.projectIdFilter}`, + ...(opts.all ? ["--all"] : []), + "--format", + formatArg, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic: sequential `Effect.all` would + // await `exitCode` (resolved by Node's "exit" event) before subscribing + // to `stdout`/`stderr` at all. Node's "exit" can fire before a fast + // process's stdio pipes are drained, so a late subscriber sees an + // already-ended, empty stream instead of the buffered bytes. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 + ? `failed to list containers: ${message}` + : "failed to list containers", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); + +/** + * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, + * `status.go:148-155`) via `docker container inspect --format + * {{json .State}}`. Go's `assertContainerHealthy` does not special-case a + * missing container — it wraps whatever error `ContainerInspect` returns + * (`status.go:148-149`), so every non-zero exit, including "no such + * container", propagates as `LegacyDockerLifecycleInspectError` carrying the + * real Docker stderr text. + */ +export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId, "--format", "{{json .State}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyDockerLifecycleInspectError({ + message: "failed to inspect container health", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleInspectError({ + message: + message.length > 0 + ? `failed to inspect container health: ${message}` + : "failed to inspect container health", + }), + ); + } + return parseContainerState(stdout); + }), + ); + +function parseContainerState(stdout: string): { + readonly running: boolean; + readonly status: string; + readonly health?: string; +} { + const trimmed = stdout.trim(); + let parsed: unknown; + try { + parsed = trimmed.length > 0 ? JSON.parse(trimmed) : {}; + } catch { + parsed = {}; + } + const state = isJsonRecord(parsed) ? parsed : {}; + // Go's `assertContainerHealthy` (`internal/status/status.go:147-156`) gates + // on the boolean `resp.State.Running`, not the status string — Docker's + // inspect `State` struct exposes both independently, and a paused or + // restarting container reports `Running: true` alongside a non-"running" + // `Status` (`"paused"`/`"restarting"`). `status` is kept as-is for the + // "container is not running: " message text (`status.go:151`), + // which still reads the string, but the gate itself must read the boolean. + const status = typeof state["Status"] === "string" ? state["Status"] : ""; + const running = state["Running"] === true; + const health = state["Health"]; + const healthStatus = + isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; + return healthStatus !== undefined + ? { running, status, health: healthStatus } + : { running, status }; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} + +/** + * Go's `Docker.VolumeList(ctx, volume.ListOptions{Filters})` + * (`docker.go` — used by the `stop` post-run volume-suggestion check) via + * `docker volume ls --filter label=`. + */ +export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => + Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "ls", + "--filter", + `label=${projectIdFilter}`, + "--format", + "{{.Name}}", + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts new file mode 100644 index 0000000000..0ecc41b372 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyDockerLifecycleInspectError, + LegacyDockerLifecycleListError, + legacyInspectContainerState, + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "./legacy-docker-lifecycle.ts"; + +function mockSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { + const encoder = new TextEncoder(); + const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), + stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyListContainersByLabel", () => { + it.live("returns container ids for a successful listing", () => { + const mock = mockSpawner({ stdout: "abc123\ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=my-app", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.ID}}", + ], + }, + ]); + }), + ); + }); + + it.live("passes --all and requests names when configured", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "names", + }).pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.Names}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no containers match", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual([]); + }), + ); + }); + + it.live("filters out blank lines from the trimmed output", () => { + const mock = mockSpawner({ stdout: "abc123\n\n \ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe( + "failed to list containers: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list containers"); + }), + ); + }); +}); + +describe("legacyInspectContainerState", () => { + it.live("parses a running, healthy container's state", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, + }), + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running", health: "healthy" }); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["container", "inspect", "supabase_db_my-app", "--format", "{{json .State}}"], + }, + ]); + }), + ); + }); + + it.live("parses a running container with no health check configured", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running" }); + }), + ); + }); + + it.live("parses a stopped/exited container", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited", Running: false }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "exited" }); + }), + ); + }); + + it.live( + "treats a paused/restarting container as running, matching Go's boolean-based gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) checks `resp.State.Running`, + // not `resp.State.Status` — a paused or restarting container reports + // `Running: true` alongside a non-"running" status string, and Go + // continues past the not-running branch in that case. + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "paused", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused" }); + }), + ); + }, + ); + + it.live( + "fails with LegacyDockerLifecycleInspectError, preserving the real stderr, when the container does not exist", + () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it + // wraps whatever `ContainerInspect` returns (`status.go:148-149`), so a + // missing container is just another non-zero exit here too. + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error response from daemon: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", + ); + }), + ); + }, + ); + + it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live( + "fails with LegacyDockerLifecycleInspectError with a generic message when stderr is empty", + () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe("failed to inspect container health"); + }), + ); + }, + ); + + it.live("treats empty inspect output as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); + + it.live("treats non-object inspect JSON as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "null" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); +}); + +describe("legacyListVolumesByLabel", () => { + it.live("returns volume names for a successful listing", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project=my-app").pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "volume", + "ls", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.Name}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no volumes remain", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.map((names) => { + expect(names).toEqual([]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "boom\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes: boom"); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes"); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index f9b301821e..915268b704 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -7,13 +7,10 @@ import { legacyApplyBitbucketDockerFilter, } from "./legacy-docker-run.args.ts"; import { LegacyDockerRunError } from "./legacy-docker-run.errors.ts"; +import { LEGACY_SUGGEST_DOCKER_INSTALL } from "./legacy-docker-suggest.ts"; import { legacyGetRegistryImageUrlCandidates } from "./legacy-docker-registry.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; -// Go's prerequisite hint (`apps/cli-go/internal/utils/docker.go:248`). -const SUGGEST_DOCKER_INSTALL = - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"; - // Go's `DockerStart` checks `os.Getenv("BITBUCKET_CLONE_DIR") != ""` // (`apps/cli-go/internal/utils/docker.go:289`) to drop named volumes / security-opts. const legacyIsBitbucketPipeline = (): boolean => { @@ -49,7 +46,9 @@ export const legacyDockerRunLayer: Layer.Layer< // Never embed the spawn error verbatim: it can leak the full argv and // environment of the failed exec (CWE-214/209). Emit a fixed, // credential-free message that still points at the likely cause. - new LegacyDockerRunError({ message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}` }); + new LegacyDockerRunError({ + message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + }); const concat = (chunks: ReadonlyArray): Uint8Array => { const total = chunks.reduce((size, chunk) => size + chunk.length, 0); @@ -309,7 +308,7 @@ export const legacyDockerRunLayer: Layer.Layer< Effect.mapError( () => new LegacyDockerRunError({ - message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}`, + message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts new file mode 100644 index 0000000000..76397c3ab1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts @@ -0,0 +1,21 @@ +/** + * Go's Docker prerequisite hint (`apps/cli-go/internal/utils/docker.go:350`, + * `suggestDockerInstall`). Go sets it as `CmdSuggestion` — rendered as a separate + * "Suggestion:" line — whenever a container-runtime call fails because the daemon + * is unreachable (`client.IsErrConnectionFailed`, `misc.go:148-154`). + */ +export const LEGACY_SUGGEST_DOCKER_INSTALL = + "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"; + +/** + * Whether a container-CLI stderr indicates the daemon is unreachable — the + * subprocess-stderr equivalent of Go's `client.IsErrConnectionFailed` (which + * inspects the Docker API client error). The docker / podman CLIs print + * "Cannot connect to the Docker daemon …" / "Cannot connect to Podman …" (often + * followed by "Is the docker daemon running?") when the socket is down. + */ +export function legacyIsDockerDaemonUnreachable(stderr: string): boolean { + return /cannot connect to the docker daemon|cannot connect to podman|is the docker daemon running/iu.test( + stderr, + ); +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts new file mode 100644 index 0000000000..0fcadd4f22 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "./legacy-docker-suggest.ts"; + +describe("legacyIsDockerDaemonUnreachable", () => { + it("detects the docker/podman daemon-down CLI messages (Go's IsErrConnectionFailed)", () => { + expect( + legacyIsDockerDaemonUnreachable( + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + ), + ).toBe(true); + // Case-insensitive + the podman phrasing. + expect(legacyIsDockerDaemonUnreachable("cannot connect to podman")).toBe(true); + expect(legacyIsDockerDaemonUnreachable("Is the docker daemon running?")).toBe(true); + }); + + it("does not flag an unrelated inspect failure (e.g. a permission error)", () => { + expect(legacyIsDockerDaemonUnreachable("permission denied while trying to connect")).toBe( + false, + ); + expect(legacyIsDockerDaemonUnreachable("Error: No such container: supabase_db_x")).toBe(false); + expect(legacyIsDockerDaemonUnreachable("")).toBe(false); + }); + + it("exposes Go's install hint verbatim", () => { + expect(LEGACY_SUGGEST_DOCKER_INSTALL).toContain("https://docs.docker.com/desktop"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts index a359ec4af7..bc07234903 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { LegacyExperimentalFlag } from "../../shared/legacy/global-flags.ts"; import { LegacyExperimentalRequiredError, @@ -8,7 +9,8 @@ import { } from "./legacy-experimental-gate.ts"; const ENV = "SUPABASE_EXPERIMENTAL"; -const withFlag = (value: boolean) => Layer.succeed(LegacyExperimentalFlag, value); +const withFlag = (value: boolean, args: ReadonlyArray = []) => + Layer.mergeAll(Layer.succeed(LegacyExperimentalFlag, value), Layer.succeed(CliArgs, { args })); describe("legacyRequireExperimental", () => { it.effect("passes when --experimental is set", () => @@ -43,4 +45,23 @@ describe("legacyRequireExperimental", () => { expect(exit._tag).toBe("Success"); }), ); + + it.effect( + "fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit (viper Changed wins)", + () => + Effect.gen(function* () { + // viper's bound-pflag lookup returns the flag value whenever Changed is true — + // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an + // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1. + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const error = yield* legacyRequireExperimental.pipe( + Effect.provide(withFlag(false, ["--experimental=false"])), + Effect.flip, + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(error).toBeInstanceOf(LegacyExperimentalRequiredError); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts new file mode 100644 index 0000000000..a7b2768edf --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -0,0 +1,197 @@ +import { createHmac, createPrivateKey, createSign } from "node:crypto"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, + * `toml`/`json` tags `kty`, `kid`, `alg`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`) — field names match exactly, so a signing-keys file can + * be parsed straight into this shape. A superset of Node's own + * `crypto.webcrypto.JsonWebKey` (which omits `kid`), so it's still assignable + * wherever that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input). + */ +export interface LegacyJwk { + readonly kty: string; + readonly kid?: string; + readonly alg?: string; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +/** + * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` + * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * {@link legacyGenerateAsymmetricGoJwt} below covers the RS256/ES256 branch of + * the same Go function, taken when `auth.signing_keys_path` is configured. + * + * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` + * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, + * a dynamic `iat`/10-year `exp`, and a different claim order, none of which + * byte-match what Go prints for `supabase status`. Go's claims, in + * declaration order (the outer `CustomClaims.Issuer` field shadows the + * embedded `jwt.RegisteredClaims.Issuer`, so only one `iss` key is emitted): + * + * iss (fixed "supabase-demo"), ref (omitempty), role, is_anonymous (omitempty), + * then the remaining `jwt.RegisteredClaims` fields (sub, aud, exp, nbf, iat, jti), + * all `omitempty` except `exp`, which Go always sets to the fixed + * `defaultJwtExpiry = 1983812996` unix timestamp (never computed from "now"). + * + * `status` never sets `ref`/`is_anonymous`, so for this signer's two roles the + * payload always serializes to exactly `{"iss":...,"role":...,"exp":...}`. + */ + +const GO_JWT_ISSUER = "supabase-demo"; +const GO_JWT_FIXED_EXP = 1983812996; + +function base64UrlEncode(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role"): string { + const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + ); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} + +/** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ +const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; + +function base64UrlToBigInt(value: string): bigint { + const hex = Buffer.from(value, "base64url").toString("hex"); + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBase64Url(value: bigint): string { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + return Buffer.from(hex, "hex").toString("base64url"); +} + +/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`a`/`m` coprime, as `q`/`p` always are for a valid RSA key). */ +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const quotient = oldR / r; + [oldR, r] = [r, oldR - quotient * r]; + [oldS, s] = [s, oldS - quotient * s]; + } + return ((oldS % m) + m) % m; +} + +/** + * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) Go's `jwkToRSAPrivateKey` + * (`apps/cli-go/pkg/config/apikeys.go:132-168`) never reads — it constructs + * `rsa.PrivateKey{N, E, D, Primes: [p, q]}` from `n`/`e`/`d`/`p`/`q` alone, and + * Go's stdlib `crypto/rsa` (`SignPKCS1v15` -> `precompute()`) lazily derives + * `Dp`/`Dq`/`Qinv` from `p`/`q`/`d` itself when they're absent, so a JWK + * missing them still signs successfully in Go. Node's + * `createPrivateKey({ format: "jwk" })` has no such fallback — it hard-rejects + * an RSA JWK without `dp`/`dq`/`qi` (`The "key.dp" property must be of type + * string`) — so this reproduces Go's derivation before handing the key to + * Node: `dp = d mod (p-1)`, `dq = d mod (q-1)`, `qi = q^-1 mod p` (RFC 7517 + * section 6.3.2 / RFC 3447 section 3.2). A key that already has all three (the common case + * for a Node/openssl-generated JWK) is returned unchanged; one missing + * `d`/`p`/`q` themselves is also returned unchanged — that's a genuinely + * invalid key in Go too, and `createPrivateKey` will raise its own error. + */ +function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { + if (jwk.dp !== undefined && jwk.dq !== undefined && jwk.qi !== undefined) { + return jwk; + } + if (jwk.d === undefined || jwk.p === undefined || jwk.q === undefined) { + return jwk; + } + const d = base64UrlToBigInt(jwk.d); + const p = base64UrlToBigInt(jwk.p); + const q = base64UrlToBigInt(jwk.q); + return { + ...jwk, + dp: bigIntToBase64Url(d % (p - 1n)), + dq: bigIntToBase64Url(d % (q - 1n)), + qi: bigIntToBase64Url(modInverse(q, p)), + }; +} + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from + * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK + * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both + * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} + * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's + * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` + * explicitly before calling this function instead of falling through to + * `CustomClaims.NewToken()`'s fixed default. + * + * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` + * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` + * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires + * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / + * `jwkToECDSAPrivateKey`, which reject any other combination rather than + * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` + * do not themselves catch this: an EC key signed as RS256, or a non-P-256 + * curve signed as ES256, both "succeed" and produce a spec-invalid token that + * silently fails verification instead of raising an error). The header key + * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically + * sorting `map[string]interface{}` keys — `kid` is only present when set on + * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * + * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA + * signature output is DER-encoded, which is not the raw (r‖s) format JWS + * requires — verified by round-tripping through `jose`'s `jwtVerify`. + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const algorithm = jwk.alg; + if (algorithm !== "RS256" && algorithm !== "ES256") { + throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); + } + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (algorithm === "ES256") { + if (jwk.kty !== "EC") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + } + const header = + jwk.kid !== undefined && jwk.kid.length > 0 + ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } + : { alg: algorithm, typ: "JWT" }; + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const payloadEncoded = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), + ); + const data = `${headerEncoded}.${payloadEncoded}`; + + const privateKey = createPrivateKey({ + key: algorithm === "RS256" ? ensureRsaCrtParams(jwk) : jwk, + format: "jwk", + }); + const signature = + algorithm === "RS256" + ? createSign("RSA-SHA256").update(data).end().sign(privateKey) + : createSign("sha256") + .update(data) + .end() + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + + return `${data}.${signature.toString("base64url")}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts new file mode 100644 index 0000000000..5bfeabfe70 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -0,0 +1,179 @@ +import { createHmac, generateKeyPairSync } from "node:crypto"; +import { importJWK, jwtVerify } from "jose"; +import { describe, expect, it } from "vitest"; + +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; + +const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; + +function generateRsaJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function generateEcJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function publicJwkOf(jwk: LegacyJwk): LegacyJwk { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + +function decodeSegment(segment: string): string { + return Buffer.from(segment, "base64url").toString("utf8"); +} + +describe("legacyGenerateGoJwt", () => { + it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header] = token.split("."); + expect(header).toBeDefined(); + // Go's jwt.NewWithClaims builds Header as map[string]any{"typ":..,"alg":..}; + // encoding/json marshals map keys in sorted order, so "alg" sorts before "typ". + expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); + }); + + it("emits the anon payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [, payload] = token.split("."); + expect(payload).toBeDefined(); + const raw = decodeSegment(payload ?? ""); + // Byte-exact key order: iss, role, exp — ref/is_anonymous/iat are omitted + // entirely (Go's `omitempty`), matching status's no-ref, non-anonymous use. + expect(raw).toBe('{"iss":"supabase-demo","role":"anon","exp":1983812996}'); + + const parsed = JSON.parse(raw) as Record; + expect(parsed).toEqual({ iss: "supabase-demo", role: "anon", exp: 1983812996 }); + expect(Object.keys(parsed)).not.toContain("iat"); + expect(Object.keys(parsed)).not.toContain("ref"); + expect(Object.keys(parsed)).not.toContain("is_anonymous"); + }); + + it("emits the service_role payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "service_role"); + const [, payload] = token.split("."); + const raw = decodeSegment(payload ?? ""); + expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); + }); + + it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header, payload, signature] = token.split("."); + const expectedSignature = createHmac("sha256", SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(signature).toBe(expectedSignature); + }); + + it("is deterministic across calls (no timestamp derived from Date.now())", () => { + const first = legacyGenerateGoJwt(SECRET, "anon"); + const second = legacyGenerateGoJwt(SECRET, "anon"); + expect(first).toBe(second); + }); + + it("produces different tokens for different secrets", () => { + const a = legacyGenerateGoJwt(SECRET, "anon"); + const b = legacyGenerateGoJwt("a-different-secret-value-1234567", "anon"); + expect(a).not.toBe(b); + }); +}); + +describe("legacyGenerateAsymmetricGoJwt", () => { + it("signs and verifies an RS256 token from an RSA JWK", async () => { + const jwk = generateRsaJwk("rsa-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { + // Go's `jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-168`) + // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` + // alone, and Go's stdlib derives the CRT params itself when absent. A + // hand-authored signing-keys file that omits them (common — RFC 7517 marks + // them optional) must still sign successfully here. + const jwk = generateRsaJwk("rsa-kid"); + const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; + const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs and verifies an ES256 token from an EC JWK", async () => { + const jwk = generateEcJwk("ec-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); + expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }); + + it("omits the kid header entirely when the JWK has no kid", () => { + const jwk = generateRsaJwk(); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [header] = token.split("."); + const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); + expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); + }); + + it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { + const jwk = generateRsaJwk(); + const before = Math.floor(Date.now() / 1000); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [, payload] = token.split("."); + const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); + const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; + expect(decoded.exp).toBeGreaterThanOrEqual(before + tenYearsSeconds); + expect(decoded.exp).toBeLessThan(before + tenYearsSeconds + 10); + }); + + it("rejects an unsupported algorithm", () => { + const jwk = { ...generateRsaJwk(), alg: "RS512" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "unsupported algorithm: RS512", + ); + }); + + it("rejects a JWK with no algorithm", () => { + const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); + }); + + it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { + const jwk = { ...generateEcJwk(), alg: "RS256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + }); + + it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { + const jwk = { ...generateRsaJwk(), alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + }); + + it("rejects an ES256 EC key whose curve is not P-256", () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); + const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + }); + + it("rejects an ES256 EC key with no curve at all", () => { + const jwk = generateEcJwk(); + const { crv: _crv, ...jwkWithoutCurve } = jwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + "unsupported curve: ", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 087363c20b..d2c6d719cd 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -1,17 +1,128 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + const LOCAL_HOST = "127.0.0.1"; +/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +const DEFAULT_CONTEXT_NAME = "default"; + +/** + * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` + * (`docker/cli` `cliconfig.Dir()`), read from here rather than + * `client.Client`'s own resolution since this module never spawns a real + * Docker client — it only needs the same two on-disk files that resolution + * reads. + */ +function dockerConfigDir(): string { + const override = process.env["DOCKER_CONFIG"]; + return override !== undefined && override.length > 0 ? override : join(homedir(), ".docker"); +} + +/** + * Go's `cli.CurrentContext()` name resolution (`docker/cli` + * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the + * config file's `currentContext`, else `"default"`. Only reached when + * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` + * when `DOCKER_HOST`/`--host` is set, which {@link legacyGetHostname} below + * already handles as its own, earlier branch. + */ +function currentDockerContextName(): string { + const fromEnv = process.env["DOCKER_CONTEXT"]; + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + try { + const config = JSON.parse(readFileSync(join(dockerConfigDir(), "config.json"), "utf8")) as { + currentContext?: unknown; + }; + if (typeof config.currentContext === "string" && config.currentContext.length > 0) { + return config.currentContext; + } + } catch { + // Missing/malformed config.json → the default context, same as Go's own + // silent fallback when it can't load the config file here. + } + return DEFAULT_CONTEXT_NAME; +} + +/** + * Reads a non-default context's daemon endpoint from Docker CLI's context + * store: `/contexts/meta//meta.json`'s + * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). + * The `"default"` context has no store entry (it's Go's synthetic + * always-available context, resolved without a client, see + * `cli.Initialize`), so it's never looked up here — matching the earlier + * `"default"` short-circuit in {@link currentDockerContextName}'s caller. + */ +function dockerContextEndpointHost(contextName: string): string | undefined { + if (contextName === DEFAULT_CONTEXT_NAME) { + return undefined; + } + try { + const contextId = createHash("sha256").update(contextName).digest("hex"); + const metaPath = join(dockerConfigDir(), "contexts", "meta", contextId, "meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { + readonly Endpoints?: { readonly docker?: { readonly Host?: unknown } }; + }; + const host = meta.Endpoints?.docker?.Host; + return typeof host === "string" && host.length > 0 ? host : undefined; + } catch { + // Missing/malformed context store entry → treat as unresolvable, same as + // Go silently falling back to the loopback default below. + return undefined; + } +} + +/** + * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring + * Go's `client.ParseHostURL` + `net.SplitHostPort` (`misc.go:307`). Returns + * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an + * unparseable one, in which case the caller falls back to the loopback + * default, matching Go's `net.SplitHostPort` failure/non-TCP handling. + */ +function hostFromTcpEndpoint(endpoint: string): string | undefined { + try { + const url = new URL(endpoint); + if (url.protocol !== "tcp:" || url.hostname.length === 0) { + return undefined; + } + // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's + // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a + // single surrounding bracket pair so local-stack probes dial/compare the + // same host Go does; IPv4 and named hosts are returned unchanged. + const host = url.hostname; + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + } catch { + return undefined; + } +} + /** * Resolves the hostname used for local Supabase service connections, mirroring - * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298`): + * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298-311`): * * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when * the Docker daemon is not reachable on the container's own loopback. * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint * (Go's `Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. `127.0.0.1` otherwise (the default unix-socket daemon). + * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a + * `tcp://` one — Go's `Docker.DaemonHost()` comes from a client built via + * `command.NewDockerCli()` + `cli.Initialize()` (`apps/cli-go/internal/ + * utils/docker.go:41-54`), whose endpoint resolution walks `DOCKER_HOST` -> + * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context + * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ + * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` + * binary this module's callers shell out to for `ps`/`inspect` already + * resolves the same active context itself, so without this step `status` + * could correctly inspect a remote daemon while printing unusable + * `127.0.0.1` API/DB/Studio URLs for it. + * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an + * unresolvable/malformed context). * * Shared across legacy commands that connect to the local stack (`gen types`, - * `test db`, and later `db reset` / `db dump`). + * `test db`, `status`, `stop`, and later `db reset` / `db dump`). */ export function legacyGetHostname(): string { const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; @@ -20,18 +131,13 @@ export function legacyGetHostname(): string { } const dockerHost = process.env["DOCKER_HOST"]; if (dockerHost !== undefined && dockerHost.length > 0) { - try { - const url = new URL(dockerHost); - if (url.protocol === "tcp:" && url.hostname.length > 0) { - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. - const host = url.hostname; - return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - } - } catch { - // Unparseable DOCKER_HOST → fall through to the loopback default. + return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; + } + const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); + if (contextEndpoint !== undefined) { + const host = hostFromTcpEndpoint(contextEndpoint); + if (host !== undefined) { + return host; } } return LOCAL_HOST; diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 9706f5f002..0f1c351d0e 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { legacyGetHostname } from "./legacy-hostname.ts"; @@ -19,6 +23,30 @@ function withEnv(entries: Record, run: () => T): } } +/** Writes a Docker CLI-shaped `$DOCKER_CONFIG` directory (`config.json` + a context store entry). */ +function writeDockerConfigDir(options: { + readonly currentContext?: string; + readonly contexts?: Readonly>; // context name -> docker.Host endpoint +}): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-hostname-docker-config-")); + if (options.currentContext !== undefined) { + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ currentContext: options.currentContext }), + ); + } + for (const [name, host] of Object.entries(options.contexts ?? {})) { + const contextId = createHash("sha256").update(name).digest("hex"); + const metaDir = join(dir, "contexts", "meta", contextId); + mkdirSync(metaDir, { recursive: true }); + writeFileSync( + join(metaDir, "meta.json"), + JSON.stringify({ Endpoints: { docker: { Host: host } } }), + ); + } + return dir; +} + describe("legacyGetHostname", () => { it("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => { expect( @@ -63,4 +91,98 @@ describe("legacyGetHostname", () => { withEnv({ SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: undefined }, legacyGetHostname), ).toBe("127.0.0.1"); }); + + describe("active Docker context resolution (Go's Docker.DaemonHost() parity)", () => { + let configDirs: Array = []; + + afterEach(() => { + for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); + configDirs = []; + }); + + function withDockerConfig( + options: Parameters[0], + env: Record, + run: () => T, + ): T { + const dir = writeDockerConfigDir(options); + configDirs.push(dir); + return withEnv( + { + SUPABASE_SERVICES_HOSTNAME: undefined, + DOCKER_HOST: undefined, + DOCKER_CONFIG: dir, + ...env, + }, + run, + ); + } + + it("resolves the host from the active context's tcp:// endpoint via config.json's currentContext", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("remote-host"); + }); + + it("prefers DOCKER_CONTEXT over config.json's currentContext", () => { + const result = withDockerConfig( + { + currentContext: "other", + contexts: { envctx: "tcp://envctx-host:2375", other: "tcp://other-host:2375" }, + }, + { DOCKER_CONTEXT: "envctx" }, + legacyGetHostname, + ); + expect(result).toBe("envctx-host"); + }); + + it("strips brackets from an IPv6 context endpoint (net.SplitHostPort parity)", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("::1"); + }); + + it("falls back to 127.0.0.1 when the active context's endpoint is not tcp://", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when the context store entry is missing", () => { + const result = withDockerConfig({ currentContext: "ghost" }, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when config.json is missing entirely (default context)", () => { + const result = withDockerConfig({}, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("never consults the context store for the default context", () => { + const result = withDockerConfig( + { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("DOCKER_HOST still takes precedence over an active non-default context", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, + { DOCKER_HOST: "tcp://direct-host:2375" }, + legacyGetHostname, + ); + expect(result).toBe("direct-host"); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts new file mode 100644 index 0000000000..a851d5a670 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -0,0 +1,1573 @@ +import { readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; +import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { Schema } from "effect"; + +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacySanitizeProjectId } from "./legacy-docker-ids.ts"; +import { + legacyApiTlsCertReadErrorMessage, + legacyApiTlsKeyReadErrorMessage, + type LegacyAnalyticsInput, + type LegacyApiInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + LegacyConfigValidateError, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyLocalSmtpInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyStudioInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; +import { + legacyCollectDotenvPrivateKeys, + legacyDecryptSecret, + legacyIsEncryptedSecret, +} from "./legacy-vault-decrypt.ts"; + +/** + * Go-parity derived local-dev config values, ported from `utils.Config`'s + * post-load defaulting (`pkg/config/config.go:406-441,748-758`) and + * `utils.GetApiUrl`/status's `toValues()` (`internal/utils/config.go:255-268`, + * `internal/status/status.go:52-95`). `@supabase/config`'s schema has no field for + * a handful of Go constants (`db.password`, the S3 credential triple) — those are + * Go-hardcoded literals, reproduced here rather than added to the shared schema + * (`pkg/config/config.go:408,437-441`). + * + * Kept generic (no `status`-specific shaping) so a future native `start`/`restart` + * port can reuse it instead of re-deriving these values — see the plan's + * "Files to create" note. Do not fold this into `legacy-storage-credentials.ts`; + * that module resolves credentials through a different (HTTP/tenant-aware) path + * for the remote-project branch, which this pure resolver does not need (the + * shared `://:` derivation itself lives in + * `legacy-api-url.ts`, used by both). + */ + +/** Go's `Db.Password` default (`pkg/config/config.go:408`) — never present in config.toml. */ +const DEFAULT_DB_PASSWORD = "postgres"; + +/** Go's hardcoded local S3 credentials (`pkg/config/config.go:437-441`). */ +const DEFAULT_S3_ACCESS_KEY_ID = "625729a08b95bf1b7ff351a663f3a23c"; +const DEFAULT_S3_SECRET_ACCESS_KEY = + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"; +const DEFAULT_S3_REGION = "local"; + +export interface LegacyLocalConfigValues { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly functionsUrl: string; + readonly mcpUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly jwtSecret: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageS3Url: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Go's `utils.GetApiUrl(path)` (`internal/utils/config.go:255-268`): appends + * `path` to the resolved external URL. Go's own fallback branch (building a bare + * `http://host:port` when `Config.Api.ExternalUrl` is empty) is unreachable in + * practice because `config.Load` already defaults `ExternalUrl` before `status` + * runs — `resolveApiExternalUrl` reproduces that same default, so `apiExternalUrl` + * passed in here is never empty. + */ +function apiUrlWithPath(apiExternalUrl: string, path: string): string { + return `${apiExternalUrl}${path}`; +} + +/** + * Thrown by {@link legacyResolveLocalConfigValues} when `auth.jwt_secret` is + * configured but too short to sign with, mirroring Go's `Config.Validate` + * (`pkg/config/apikeys.go:45-47`) — that check runs at config-load time, before + * any command renders output, so no local dev stack can even start with a + * short secret. + */ +export class LegacyInvalidJwtSecretError extends Error { + constructor() { + super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + this.name = "LegacyInvalidJwtSecretError"; + } +} + +/** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ +const MIN_JWT_SECRET_LENGTH = 16; + +/** + * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port, mirroring Go's `Config.Load` + * (`pkg/config/config.go:749-756`): `v.UnmarshalExact` decodes with + * `WeaklyTypedInput` on (viper's `defaultDecoderConfig`, never reset by our + * decoder options), so mapstructure's `decodeUint` runs `strconv.ParseUint` + * on the override string and hard-fails config loading on a bad value — + * there is no Go code path that reaches `status`/`stop` with a malformed + * port override. The message text isn't a byte-match for mapstructure's + * internal error (that's viper/mapstructure library text, not a Go-authored + * string), but the parity-relevant part — hard-fail, same field name — is. + */ +export class LegacyInvalidPortEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); + this.name = "LegacyInvalidPortEnvOverrideError"; + } +} + +/** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ +const MAX_PORT = 65535; + +/** + * Port-flavored sibling of {@link envOverride}/{@link legacyEnvOverrideBool} + * for `SUPABASE_*_PORT` fields Go decodes as `uint16` rather than a plain + * string. Unlike the boolean sibling — which intentionally falls back to + * `configured` on a malformed override — a bad port override is a genuine + * Go-parity hard failure (see {@link LegacyInvalidPortEnvOverrideError}), not + * a leniency case: Go never proceeds with the pre-override value on a decode + * error, it fails config loading outright. + */ +function envOverridePort( + name: string, + configuredPort: number, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configuredPort; + if (!/^\d+$/.test(value)) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + const port = Number(value); + if (port > MAX_PORT) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + return port; +} + +/** + * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), + * so ANY config field can be overridden by a `SUPABASE_` env var, + * generically across the whole struct — not just auth fields + * (`config_test.go:351,1061` exercise this against `auth.site_url`, and + * `internal/status/status.go:52-95`'s `toValues()` reads `utils.Config.*` + * directly, so every already-overridden field is automatically reflected in + * `status`'s output). This resolves it for every field this module derives a + * URL/port from, at the same higher-than-config.toml precedence Viper gives + * env vars. An empty env var is treated as unset, matching Viper's default + * (`AllowEmptyEnv` is never enabled in `config.go`). + * + * Viper's `AutomaticEnv` binding runs AFTER `Config.Load`'s `loadNestedEnv` + * (`config.go:735-738`), which loads `supabase/.env`(.local) and project-root + * dotenv files into the process env before any `SUPABASE_*` var is read + * (`config.go:1169-1207`) — so a value that lives only in one of those files, + * not the ambient shell, must still be visible here. `projectEnvValues` is + * that already-resolved map (see `legacyResolveProjectEnvironmentValues`); + * falling back to `process.env` covers the "no `supabase/` project found" + * case, where `projectEnvValues` is `undefined`. + * + * The resolved override string itself can be a further `env(VAR)` indirection + * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`) — Go's `LoadEnvHook` + * (`decode_hooks.go:15-23`) is the first mapstructure decode hook composed + * into `v.UnmarshalExact` (`config.go:749-753,769-772`), so it resolves + * `env(...)` on every string mapstructure decodes into the struct, regardless + * of whether Viper sourced that string from `config.toml` or a `SUPABASE_*` + * `AutomaticEnv` override (`config.go:582-586`) — Viper's `Get()` just returns + * a string; the hook chain doesn't know or care where it came from. Resolved + * with the same `projectEnvValues ?? process.env` precedence and non-empty + * gate as the outer lookup (mirroring `decode_hooks.go:19-24`'s `len(env) > 0` + * check); an unresolved/empty indirection leaves the `env(VAR)` literal + * untouched, same as Go. + */ +function envOverride( + name: string, + configured: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const value = projectEnvValues?.[name] ?? process.env[name]; + if (value === undefined || value.length === 0) return configured; + const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (indirection === undefined) return value; + const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; + return resolved !== undefined && resolved.length > 0 ? resolved : value; +} + +/** + * Thrown by {@link legacyEnvOverrideBool} when a `SUPABASE_*_ENABLED` (or other + * bool-typed) env/dotenv override doesn't parse as one of Go's accepted bool + * spellings, mirroring Go's `Config.Load` (`pkg/config/config.go:749-756`): + * `v.UnmarshalExact` decodes with `WeaklyTypedInput` on (viper's + * `defaultDecoderConfig`, never reset by our decoder options — same mechanism + * as {@link LegacyInvalidPortEnvOverrideError}), so mapstructure's `decodeBool` + * runs `strconv.ParseBool` on the override string and hard-fails config + * loading on a bad value — there is no Go code path that reaches `status`/ + * `stop` with a malformed bool override. + */ +export class LegacyInvalidBoolEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + this.name = "LegacyInvalidBoolEnvOverrideError"; + } +} + +/** + * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go + * decodes as a native bool (`api.tls.enabled`, `auth.enabled`, and every other + * `

.enabled` gate `status`/`stop` read — see `status.values.ts`) + * rather than a string/number — those are bound by the same generic Viper + * mechanism (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()`, `pkg/config/config.go:582-586`), but the override string + * must be decoded with Go's own `strconv.ParseBool` acceptance set + * ({@link legacyParseGoBool}) instead of used verbatim. Unlike a plain string + * override — where an unparsed value has no Go-observable failure mode — a + * malformed bool override is a genuine Go-parity hard failure (see + * {@link LegacyInvalidBoolEnvOverrideError}), same as + * {@link LegacyInvalidPortEnvOverrideError} for ports: Go never proceeds with + * the pre-override value on a decode error, it fails config loading outright. + * + * Exported (not just used internally) because `status.values.ts`'s own + * `
.enabled` gates need this same override treatment — Go's + * `status.toValues()` reads `utils.Config.*.Enabled` post-Viper-override for + * every gated service, not only auth. + */ +export function legacyEnvOverrideBool( + name: string, + configured: boolean, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): boolean { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configured; + const parsed = legacyParseGoBool(value); + if (parsed === undefined) { + throw new LegacyInvalidBoolEnvOverrideError(dottedFieldPath, value); + } + return parsed; +} + +/** + * Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't match one of Go's `LogflareBackend` values. `Analytics.Backend` is + * typed `LogflareBackend` (`pkg/config/config.go:303`), and + * `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects anything + * outside `{postgres, bigquery}` — that runs inside the same + * `v.UnmarshalExact` decode call (`config.go:749-756`) every other + * `SUPABASE_*` override goes through, so a malformed override fails config + * loading outright, same mechanism as {@link LegacyInvalidPortEnvOverrideError}/ + * {@link LegacyInvalidBoolEnvOverrideError}. + */ +export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super( + `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + ); + this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; + } +} + +/** + * `analytics.backend`-flavored sibling of {@link envOverridePort}/ + * {@link legacyEnvOverrideBool} for the one `SUPABASE_*` override this file + * decodes as a Go text-unmarshalled enum rather than a string/number/bool — + * see {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. Validates the + * override-or-configured value with a SINGLE check (rather than only + * validating the override, trusting the schema for the configured value), + * matching Go more closely: viper merges the config.toml value and any env + * override into one string BEFORE `UnmarshalExact` calls `UnmarshalText` + * exactly once on the resolved value (`config.go:749-756`), not once per + * source. `@supabase/config`'s `stringEnum` (`packages/config/src/ + * analytics.ts:31-39`) already guards the `config.toml`-sourced value at + * decode time, so this is belt-and-suspenders for that source and the sole + * guard for the env-override one, which bypasses that schema entirely. + */ +function envOverrideAnalyticsBackend( + configured: string, + projectEnvValues: Readonly> | undefined, +): "postgres" | "bigquery" { + const value = + envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + if (value !== "postgres" && value !== "bigquery") { + throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); + } + return value; +} + +/** + * Decrypts a resolved auth identity-key field (`jwt_secret`, `publishable_key`, + * `secret_key`, `anon_key`, `service_role_key`) when it's a dotenvx `encrypted:` + * value, mirroring Go's `DecryptSecretHookFunc` (`pkg/config/secret.go:30-73`), + * which Go runs unconditionally during `UnmarshalExact` for every + * `config.Secret`-typed field (`pkg/config/auth.go:181-185` types these five as + * `Secret`) — an undecryptable value aborts config loading with + * `failed to parse config: ` (`config.go:704`) before `status`/`stop` + * continue. `@supabase/config`'s schema only tags these fields for later + * `Redacted` wrapping (`packages/config/src/lib/env.ts`) and never decrypts, so + * without this step a valid `encrypted:` secret would be used as literal (wrong) + * key material and a malformed one would silently pass through instead of + * failing like Go does. + * + * Applied AFTER {@link envOverride}, matching Go: an env-sourced override lands + * on the same `config.Secret` field and goes through the same decode hook as a + * TOML-sourced value, so `SUPABASE_AUTH_JWT_SECRET=encrypted:...` is decrypted + * too, not just the config.toml value. + */ +function decryptAuthSecret( + value: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + if (value === undefined || !legacyIsEncryptedSecret(value)) return value; + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnvValues, ...process.env }); + const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); + if (!decrypted.ok) { + throw new LegacyConfigValidateError(`failed to parse config: ${decrypted.error}`); + } + return decrypted.value; +} + +/** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ +function resolveJwtSecret(configured: string | undefined): string { + if (configured === undefined || configured.length === 0) return defaultJwtSecret; + if (configured.length < MIN_JWT_SECRET_LENGTH) { + throw new LegacyInvalidJwtSecretError(); + } + return configured; +} + +function resolveOpaqueKey(configured: string | undefined, fallback: string): string { + return configured !== undefined && configured.length > 0 ? configured : fallback; +} + +function resolveSignedKey( + configured: string | undefined, + jwtSecret: string, + signingKey: LegacyJwk | undefined, + role: "anon" | "service_role", +): string { + if (configured !== undefined && configured.length > 0) return configured; + return signingKey !== undefined + ? legacyGenerateAsymmetricGoJwt(signingKey, role) + : legacyGenerateGoJwt(jwtSecret, role); +} + +/** Matches Go's `JWK` struct fields (`pkg/config/auth.go:88-108`) — see `LegacyJwk`. */ +const LegacyJwkSchema = Schema.Struct({ + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + alg: Schema.optionalKey(Schema.String), + n: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)); + +/** + * Go's `Config.Validate` (`pkg/config/config.go:877-878,1059-1062`): a relative + * `signing_keys_path` resolves against `/supabase`, then the file is + * read and JSON-decoded into `[]JWK`. Only the first key is ever used + * ({@link resolveSignedKey}), matching `generateJWT`'s `a.SigningKeys[0]`. + * + * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity + * resolvers in `legacy/` use for file reads) so this function — and its large + * existing test surface — can stay a plain synchronous resolver; this is an + * optional, rarely-configured field, not worth threading Effect dependencies + * through `legacyStatusValues`/`status.handler.ts` for. + * + * Error wording matches Go's two `Validate` failure branches exactly + * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode + * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ + * `JSON.parse`'s raw Node error text through unwrapped. + * + * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- + * overridden value, not necessarily raw `config.auth.enabled` — see + * {@link legacyEnvOverrideBool}) — Go's `Validate` nests the entire signing-keys read + * inside `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`), reading + * that same post-override value, so a disabled auth section never touches + * `signing_keys_path`, however stale or missing that file is. + */ +function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { + const absolutePath = legacyResolveSigningKeysPath(workdir, signingKeysPath); + + let contents: string; + try { + contents = readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)); + } + + let jwks: ReadonlyArray; + try { + jwks = decodeLegacyJwks(JSON.parse(contents)); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)); + } + return jwks[0]; +} + +/** + * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`) file reads: gated on + * `api.enabled && api.tls.enabled` same as the caller, each configured path is read to confirm + * it's actually reachable, matching Go's `fs.ReadFile` calls (Go caches the bytes for `start` to + * serve as `CertContent`/`KeyContent` — `status`/`stop` have no use for the bytes, only the same + * validation outcome, so they're discarded here). The "exactly one of cert/key set" presence + * check now lives in `legacyValidateResolvedConfig`'s `api.tls` step + * (`legacy-config-validate.ts`) — this function only runs the reads, and only when BOTH paths + * are actually present: neither path set, or only one, never reaches a `fs.ReadFile` call here, + * since the presence check (run later, as part of the single consolidated validation call) owns + * rejecting the one-but-not-the-other case. + * + * Go joins both paths unconditionally with the `supabase/` dir — no `filepath.IsAbs` guard + * (`config.go:961-965` uses `path.Join`, which absorbs a leading `/`) — unlike + * {@link loadFirstSigningKey}'s `signing_keys_path`, which Go does guard with `filepath.IsAbs` + * (`config.go:928-929`). See `legacyResolveApiTlsPath`. Matches the identical Kong-side + * validation already ported for `seed buckets`/`storage` in + * `legacy-storage-credentials.ts`'s `validateLocalKongTls`. + * + * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: this stays a plain + * synchronous resolver rather than threading the Effect `FileSystem` service through + * `legacyStatusValues`/`status.handler.ts`. + */ +function readApiTlsFiles( + workdir: string, + certPath: string | undefined, + keyPath: string | undefined, +): void { + if (certPath === undefined || certPath.length === 0) return; + if (keyPath === undefined || keyPath.length === 0) return; + + try { + readFileSync(legacyResolveApiTlsPath(workdir, certPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)); + } + try { + readFileSync(legacyResolveApiTlsPath(workdir, keyPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)); + } +} + +/** + * Go's `(e *email) validate(fsys)` template/notification content read (`pkg/config/ + * config.go:1293-1313`), called from `Config.Validate` right after `Auth.MFA.validate()`, still + * inside `if c.Auth.Enabled` (`config.go:1142`). Every template is checked unconditionally; a + * notification only when that notification is itself enabled (`config.go:1308`). Uses the same + * `readFileSync`-based pattern as {@link loadFirstSigningKey}/`readApiTlsFiles` in this file, + * not an Effect `FileSystem` service. + * + * The `content`-vs-`content_path` exclusivity decision and path resolution (including the + * TEMPLATE-vs-`workdir`/NOTIFICATION-vs-`/supabase` base asymmetry, per Go's `(c + * *baseConfig) resolve` (`config.go:900-916`) — this asymmetry is real, intentional Go behavior + * to match, not a bug to fix) now live in `legacyResolveEmailTemplateContentPath` + * (`legacy-config-validate.ts`); this function only feeds it `contentPresent` (computed from the + * raw `document`, since `@supabase/config`'s `template`/`notification` schema + * (`packages/config/src/auth/email.ts`) has no `content` field to see) and performs the read + * when a path comes back. + * + * `auth.email.template..*`/`auth.email.notification..*` are Viper-bound like every + * other nested field once `[auth.email.template.]`/`[auth.email.notification.]` are + * present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), so + * `SUPABASE_AUTH_EMAIL_TEMPLATE__CONTENT_PATH`/`SUPABASE_AUTH_EMAIL_NOTIFICATION__ + * ENABLED`/`_CONTENT_PATH` overrides apply before this read runs. Unlike the hook/passkey/smtp + * presence gates elsewhere in this file, no extra raw-document presence check is needed here to + * decide WHETHER to apply an override: `email.template`/`email.notification` are `Schema.Record`s + * (`packages/config/src/auth/email.ts`), which — unlike a fixed-shape struct with + * `withDecodingDefaultKey` — only ever contain a key when the TOML section was actually present, + * so `Object.entries` already reflects presence. + */ +function readAuthEmailTemplateContent( + email: ProjectConfig["auth"]["email"], + workdir: string, + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const emailDoc = asRecord(authDocument?.["email"]); + const templatesDoc = asRecord(emailDoc?.["template"]); + const notificationsDoc = asRecord(emailDoc?.["notification"]); + + for (const [name, tmpl] of Object.entries(email.template)) { + const contentPath = + envOverride( + `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}_CONTENT_PATH`, + tmpl.content_path, + projectEnvValues, + ) ?? tmpl.content_path; + const path = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath, + contentPresent: asRecord(templatesDoc?.[name])?.["content"] !== undefined, + base: workdir, + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("template", name, cause), + ); + } + } + for (const [name, tmpl] of Object.entries(email.notification)) { + const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + tmpl.enabled, + `auth.email.notification.${name}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + const contentPath = + envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path; + const path = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath, + contentPresent: asRecord(notificationsDoc?.[name])?.["content"] !== undefined, + base: join(workdir, "supabase"), + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("notification", name, cause), + ); + } + } +} + +/** + * `SUPABASE_DB_MAJOR_VERSION` sibling of {@link envOverridePort} for the one + * numeric field Go decodes as `uint` rather than `uint16` (`pkg/config/db.go:87`) + * — same generic Viper `AutomaticEnv` binding (`config.go:576-586`), same + * mapstructure hard-fail-on-bad-value semantics as the port/bool overrides, but + * with no upper-bound cap. A non-digit override folds into the same generic + * "Invalid db.major_version" message `legacyValidateResolvedConfig` produces for + * an out-of-set numeric value, since Go's own decode failure and `Validate` + * failure for this field aren't independently distinguishable from the CLI's + * output the way ports/bools are. + */ +function envOverrideMajorVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_DB_MAJOR_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid db.major_version: ${value}.`); + } + return Number(value); +} + +/** + * `SUPABASE_EDGE_RUNTIME_DENO_VERSION` sibling of {@link envOverrideMajorVersion} + * — same generic Viper `AutomaticEnv` binding, same mapstructure + * hard-fail-on-bad-value semantics, no upper-bound cap. A non-digit override + * folds into the same generic "Invalid edge_runtime.deno_version" message + * `legacyValidateResolvedConfig` produces for an out-of-set numeric value. + */ +function envOverrideDenoVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${value}.`); + } + return Number(value); +} + +/** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`), used + * only to build {@link legacyResolveLocalConfigValues}'s `hooks` input in the right order — + * the actual per-hook validation now lives in `legacyValidateResolvedConfig`. */ +const LEGACY_HOOK_TYPE_ORDER = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +] as const; + +/** Go's `(s *sms) validate()` fixed provider priority (`pkg/config/config.go:1348-1410`) — a + * `switch` that validates ONLY the first enabled provider in this order. */ +const LEGACY_SMS_PROVIDER_ORDER = [ + "twilio", + "twilio_verify", + "messagebird", + "textlocal", + "vonage", +] as const; + +/** Required fields per SMS provider, in the order Go checks them (`config.go:1349-1403`). */ +const LEGACY_SMS_REQUIRED_FIELDS: Record< + (typeof LEGACY_SMS_PROVIDER_ORDER)[number], + ReadonlyArray +> = { + twilio: ["account_sid", "message_service_sid", "auth_token"], + twilio_verify: ["account_sid", "message_service_sid", "auth_token"], + messagebird: ["originator", "access_key"], + textlocal: ["sender", "api_key"], + vonage: ["from", "api_key", "api_secret"], +}; + +/** + * Go's `(s *sms) validate()` (`pkg/config/config.go:1348-1410`): a boolean `switch` that inspects + * providers in the FIXED priority order above and validates ONLY the first one whose `enabled` is + * true — a later enabled-but-incomplete provider is never even looked at. `@supabase/config`'s + * `sms` schema (`packages/config/src/auth/sms.ts`) already implements this exact switch for the + * schema-decoded (pre-env-override) TOML value, which is Go-parity-correct for a config with no + * relevant `SUPABASE_AUTH_SMS_*` env override — but `@supabase/config`'s decode pipeline never + * resolves `SUPABASE_*` overrides at all (only this legacy-shell layer does, post-decode), so a + * `SUPABASE_AUTH_SMS__ENABLED` override that flips a section's enabled state after + * decode is invisible to it. This re-runs the same switch against the RAW `authDocument` with + * env overrides applied first — same document-based, post-override pattern as + * {@link validateAuthExternalProviders} below, and the same "duplicate D's/the schema's check for + * the env-override-aware L path" tradeoff already accepted for that function. + * + * `auth.sms..*` is Viper-bound like every other nested field once + * `[auth.sms.]` is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, + * `config.go:581-586`), so `SUPABASE_AUTH_SMS__ENABLED`/`_` overrides apply + * before this validation runs, gated on the raw provider section already being present, matching + * `AutomaticEnv` (which only intercepts keys already present in the merged config). + */ +function validateAuthSmsProviders( + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const smsDoc = asRecord(authDocument?.["sms"]); + if (smsDoc === undefined) return; + for (const providerName of LEGACY_SMS_PROVIDER_ORDER) { + const providerDoc = asRecord(smsDoc[providerName]); + if (providerDoc === undefined) continue; + const envPrefix = `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + providerDoc["enabled"] === true, + `auth.sms.${providerName}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + for (const field of LEGACY_SMS_REQUIRED_FIELDS[providerName]) { + const value = envOverride( + `${envPrefix}_${field.toUpperCase()}`, + typeof providerDoc[field] === "string" ? providerDoc[field] : undefined, + projectEnvValues, + ); + if (value === undefined || value.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.sms.${providerName}.${field}`, + ); + } + } + // Go's switch stops at the first enabled provider — later providers are never inspected. + return; + } +} + +/** Go's `external.validate()` deprecated-provider skip (`config.go:1419-1423`) — `linkedin`/ + * `slack` are deleted (and warned on, if enabled) before the required-field loop runs, so they + * are never validated here. Mirrors `legacy-db-config.toml-read.ts`'s identical "B5: external + * providers" skip list. */ +const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); + +/** + * Go's `(e external) validate()` (`pkg/config/config.go:1419-1451`) — D-only per + * `legacy-config-validate.ts`'s module header ("`auth.external` ... stays 100% inline in D"), so + * this ports the identical inline block D already has (`legacy-db-config.toml-read.ts`'s "B5: + * external providers") to close the same gap for L. `auth.external` is a genuine Go + * `map[string]provider` (`apps/cli-go/pkg/config/auth.go:190`), so an arbitrary/unmodeled + * provider name (e.g. `[auth.external.custom]`) is a legitimate config shape — Go validates + * every enabled entry regardless of name. `@supabase/config`'s `external` schema only models the + * ~20 known provider ids and silently drops anything else at decode time + * (`packages/config/src/auth/providers.ts`), so an unmodeled provider's required-field check + * must run against the RAW `authDocument` instead of the decoded `ProjectConfig` — same + * document-based approach as {@link readAuthEmailTemplateContent}/the passkey/smtp checks above. + * Known providers are already covered by the schema's own `requiredWhenEnabled` check at decode + * time, so in practice this only ever fires for a name the schema doesn't model, but it runs + * over every raw key unconditionally, matching Go's own map iteration rather than special-casing + * "unknown" a different way. `authDocument`'s values are already post-`env()`-interpolation (see + * `LoadedProjectConfig.document`), so no `legacyExpandEnv`-style resolution is needed here, + * unlike D's raw pre-interpolation document. + * + * `auth.external..*` is Viper-bound like every other nested field once + * `[auth.external.]` is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, + * `config.go:581-586`), so `SUPABASE_AUTH_EXTERNAL__ENABLED`/`_CLIENT_ID`/`_SECRET` + * overrides apply before this validation runs — same gap this schema's own `requiredWhenEnabled` + * check has for KNOWN providers too (that check only sees the decoded, pre-override TOML value), + * so this now covers both known and unmodeled provider names uniformly, matching Go not + * distinguishing between them either. + */ +function validateAuthExternalProviders( + authDocument: Record | undefined, + projectEnvValues: Readonly> | undefined, +): void { + const external = asRecord(authDocument?.["external"]); + if (external === undefined) return; + for (const name of Object.keys(external)) { + if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + const envPrefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; + const enabled = legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + provider["enabled"] === true, + `auth.external.${name}.enabled`, + projectEnvValues, + ); + if (!enabled) continue; + const clientId = envOverride( + `${envPrefix}_CLIENT_ID`, + typeof provider["client_id"] === "string" ? provider["client_id"] : undefined, + projectEnvValues, + ); + if (clientId === undefined || clientId.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.external.${name}.client_id`, + ); + } + const secret = envOverride( + `${envPrefix}_SECRET`, + typeof provider["secret"] === "string" ? provider["secret"] : undefined, + projectEnvValues, + ); + if (name !== "apple" && name !== "google" && (secret === undefined || secret.length === 0)) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.external.${name}.secret`, + ); + } + } +} + +/** + * @throws when `project_id` (post-override, post-workdir-basename-fallback) is + * an explicit empty string. Go's `Config.Validate` checks this FIRST, before + * every other field (`pkg/config/config.go:990-991`): `mergeDefaultValues` + * merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper DEFAULT value + * BEFORE `config.toml` is merged (`config.go:690-699`, via `Eject`, + * `config.go:561-570`) — so `c.ProjectId` is NEVER Go's zero value by the time + * `Validate` runs; it's always at least this sanitized basename. A workdir + * whose basename sanitizes to the empty string (e.g. `!!!`) therefore fails + * config loading in Go even with NO `project_id` key in the file at all. An + * explicit `project_id = ""` IN the file overwrites that default with the + * literal empty string the same way (rather than being treated as absent) — + * Go fails outright rather than falling back to the basename either way. + * `legacySanitizeProjectId` is only applied to the BASENAME fallback here, + * matching `Eject`'s pre-sanitized default — an explicit non-empty + * `config.project_id`/`SUPABASE_PROJECT_ID` value is intentionally NOT + * re-sanitized at this point, matching Go's `Validate` "auto-fix" branch + * (`config.go:992-996`) being a WARN-only rewrite with no throwing + * equivalent, same precedent as this module's other WARN-only omissions + * (`auth.captcha.secret`/`assertEnvLoaded`, SMS's `EnableSignup` case). + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @throws when a configured `api.tls` cert/key file can't be read — see + * {@link readApiTlsFiles}. The "exactly one of cert/key set" presence check + * runs later, as part of {@link legacyValidateResolvedConfig}. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key uses an unsupported algorithm — see {@link loadFirstSigningKey} + * and {@link legacyGenerateAsymmetricGoJwt}. + * @throws when an email template's `content` is present without `content_path`, or a + * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. + * @throws {LegacyInvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't parse as one of Go's `LogflareBackend` values. + * @throws {LegacyConfigValidateError} for every other `Config.Validate` branch this module + * and `legacy-config-validate.ts` jointly own — project_id emptiness aside (checked above, + * inline, since the value is also needed for the throw's own message-free early-exit shape), + * every REMAINING pure check (api.port/tls presence, db.port/major_version, storage bucket + * names, studio, local_smtp, auth.site_url/captcha/passkey/hooks/mfa/smtp/third_party, + * function slugs, edge_runtime.deno_version, analytics.gcp_*, experimental.*) is deferred to a + * SINGLE call to {@link legacyValidateResolvedConfig} at the end of this function, in Go's exact + * relative order — see that module's header for the full table and the accepted ordering + * tradeoff this introduces against the I/O checks listed above (which keep running at their + * original position, per-caller, rather than being folded into that single call). + */ +export function legacyResolveLocalConfigValues( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** + * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, + * pre-schema-default TOML document `config` was decoded from. Lets checks + * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted + * value) inspect the file directly — see `legacyValidateResolvedConfig`'s + * `experimental.webhooks`/`auth.passkey`/`auth.email.smtp` steps. + * `undefined` for callers that haven't threaded it through yet (e.g. most + * existing unit tests); those checks are then simply skipped rather than + * guessed at. + */ + document: Readonly> | undefined = undefined, +): LegacyLocalConfigValues { + // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field + // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above + // for why a workdir basename that sanitizes to `""` fails here even when + // `project_id` is absent from the file entirely. `config.project_id` is + // `undefined` only when the key is genuinely absent (`optionalKey`, see + // `packages/config/src/base.ts`) — that's the ONE case where Go's own + // sanitized-basename viper default shows through instead of a file value, + // so the fallback belongs here, not as a third branch after `envOverride`. + // `SUPABASE_PROJECT_ID` is checked via the same `envOverride` precedence + // every other field here uses, since Viper's `AutomaticEnv` binds it too + // (`config.go:529-535`) and it can turn an explicit-empty file value (or an + // unsanitizable basename fallback) back into a valid override. + const resolvedProjectId = envOverride( + "SUPABASE_PROJECT_ID", + config.project_id ?? legacySanitizeProjectId(basename(workdir)), + projectEnvValues, + ); + + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` + // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override + // (`config.go:529-535,799-809`), so the values fed into + // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation (which picks `https` vs `http` from + // `tls.enabled`) must be the overridden ones too. + const apiTlsEnabled = legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); + // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` + // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the + // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const apiTlsCertPath = envOverride( + "SUPABASE_API_TLS_CERT_PATH", + config.api.tls.cert_path, + projectEnvValues, + ); + const apiTlsKeyPath = envOverride( + "SUPABASE_API_TLS_KEY_PATH", + config.api.tls.key_path, + projectEnvValues, + ); + if (apiEnabled && apiTlsEnabled) { + readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + } + // Go's `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY + // when `api.enabled` (`pkg/config/config.go:1006-1008`) — unlike `db.port` + // below, which has no `enabled` gate. Resolved once into a named const so the + // check and the URL derivation below share the same overridden value instead + // of calling `envOverridePort` twice. + const apiPort = envOverridePort( + "SUPABASE_API_PORT", + config.api.port, + "api.port", + projectEnvValues, + ); + const apiExternalUrl = legacyResolveApiExternalUrl( + { + external_url: envOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, + ); + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no + // `enabled` gate in Go's `Config.Validate` — it's unconditionally required, + // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this + // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` + // render anything, same wording already used for the `db query`/`test db` + // path (`legacy-db-config.toml-read.ts:1380`). + const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + // Go's `Config.Validate` checks `db.major_version` right after `db.port` + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). + const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); + // Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + // key right after `db.major_version`, unconditionally. + const storageBucketNames = + config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; + // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` + // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same + // enabled-gated pattern as `api.port` above. + const studioEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = envOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); + // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right + // after the port check, still inside `if c.Studio.Enabled` + // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required + // (defaulted) field, so `envOverride` can only return `undefined` here if + // that default itself were somehow undefined — the `??` fallback just + // satisfies that generic signature. + const studioApiUrl = + envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url; + // Go's `Config.Validate` rejects `local_smtp.port === 0`/ + // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct + // field is still named `Inbucket` for the `[local_smtp]` TOML section + // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the + // deprecated `inbucket.enabled` alias are the same underlying flag, not two + // independent ones. + const mailpitEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = envOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + const jwtSecret = resolveJwtSecret( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + projectEnvValues, + ), + ); + const signingKeysPath = envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); + // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): + // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a + // disabled auth section never opens/parses `signing_keys_path`, even a stale + // or missing one. JWT-secret validation and anon/service_role key generation + // (`generateAPIKeys`, `apikeys.go:43-73`) run unconditionally either way, so + // only this file read is gated. `c.Auth.Enabled` is itself Viper-bound like + // any other field (`config.go:582-586`), so `Validate`'s gate reads the + // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence + // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. + const authEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — + // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT + // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as + // `""` with no schema-level error, same gap as `db.port === 0` above. + const siteUrl = envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues); + // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — + // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because + // the captcha presence check right below needs it too. `undefined` for callers that haven't + // threaded `document` through yet, in which case presence-based checks are simply skipped. + const authDocument = asRecord(document?.["auth"]); + // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`): an + // enabled CAPTCHA section requires both `provider` and `secret`. `auth.captcha.*` + // is Viper-bound like every other nested field once `[auth.captcha]` is present + // in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), + // so `SUPABASE_AUTH_CAPTCHA_ENABLED`/`_PROVIDER`/`_SECRET` overrides apply before + // this validation runs. Unlike the flat `auth.site_url` field, `config.auth.captcha` + // does NOT decode to `undefined` when `[auth.captcha]` is absent from config.toml — + // `captcha.ts`'s own `withDecodingDefaultKey` fills in `{ enabled: false }` even + // through the outer `Schema.optionalKey` wrapper (`packages/config/src/auth/index.ts`), + // confirmed empirically; there is no schema-level presence signal here, unlike + // `auth.passkey`/`auth.webauthn` below. So, like those, presence is read from the raw + // `authDocument` instead — matching Go's `AutomaticEnv` (which only intercepts keys + // already present in the merged config), an absent `[auth.captcha]` section never + // picks up an env override alone. + const captchaDoc = asRecord(authDocument?.["captcha"]); + const captchaInput: LegacyCaptchaInput | undefined = config.auth.captcha + ? { + enabled: + captchaDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_CAPTCHA_ENABLED", + config.auth.captcha.enabled ?? false, + "auth.captcha.enabled", + projectEnvValues, + ) + : (config.auth.captcha.enabled ?? false), + provider: + captchaDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_CAPTCHA_PROVIDER", + config.auth.captcha.provider, + projectEnvValues, + ) + : config.auth.captcha.provider, + secret: + captchaDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_CAPTCHA_SECRET", + config.auth.captcha.secret, + projectEnvValues, + ) + : config.auth.captcha.secret, + } + : undefined; + const signingKey = + authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? loadFirstSigningKey(workdir, signingKeysPath) + : undefined; + // Go's `Config.Validate` runs passkey/webauthn validation, then + // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then + // `Auth.Email.validate()`, then `Auth.Sms.validate()`/`Auth.ThirdParty.validate()` (skipping + // the D-only `external` step, ported separately below), all right after the signing-keys read + // and still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). Sms + // (`config.go:1145-1147`/`1348-1417`) is enforced at decode time by `@supabase/config`'s `sms` + // schema (`packages/config/src/auth/sms.ts`'s provider-switch check) for the TOML-only case, + // AND re-checked here post-env-override by {@link validateAuthSmsProviders} (called alongside + // {@link validateAuthExternalProviders}, after the single `legacyValidateResolvedConfig` call + // below) — see that function's doc comment for why both are needed. External + // (`config.go:1148-1150`/`1419-1451`) is D-only per `legacy-config-validate.ts`'s module + // header; {@link validateAuthExternalProviders} ports D's identical inline check. This block + // only ACCUMULATES the inputs those checks need — the checks themselves run once, later, as + // part of the single `legacyValidateResolvedConfig` call below. + let authInput: LegacyAuthInput | undefined; + if (authEnabled) { + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see + // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn + // are read from the RAW, post-`env()`-interpolation TOML document (`authDocument`, hoisted + // above) instead of the decoded `ProjectConfig` — same document-based approach already used + // on the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s + // `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when a caller hasn't + // threaded `document` through yet, in which case passkey/smtp presence-based checks are + // simply skipped rather than guessed at. + const passkeyDoc = asRecord(authDocument?.["passkey"]); + const webauthnDoc = asRecord(authDocument?.["webauthn"]); + // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like every other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml (`ExperimentalBindStruct`/ + // `AutomaticEnv`, `config.go:581-586`), so `SUPABASE_AUTH_PASSKEY_ENABLED` and + // `SUPABASE_AUTH_WEBAUTHN_RP_ID`/`_RP_ORIGINS` overrides apply before `Auth.Passkey`/ + // `Auth.Webauthn` validation runs (`config.go:1117-1134`). Gated on the raw section already + // being present (`passkeyDoc`/`webauthnDoc !== undefined`), matching Go's `AutomaticEnv` + // (which only intercepts keys already present in the merged config) — an absent + // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. + const passkeyEnabled = + passkeyDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_PASSKEY_ENABLED", + passkeyDoc["enabled"] === true, + "auth.passkey.enabled", + projectEnvValues, + ) + : false; + const rpId = + webauthnDoc !== undefined + ? envOverride( + "SUPABASE_AUTH_WEBAUTHN_RP_ID", + typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined, + projectEnvValues, + ) + : undefined; + // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` + // mapstructure hook as every other Go string-slice field (`config.go:775-784`), so a + // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. + const rpOriginsOverride = + webauthnDoc !== undefined + ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + : undefined; + const rpOrigins = + rpOriginsOverride !== undefined + ? rpOriginsOverride.split(",") + : Array.isArray(webauthnDoc?.["rp_origins"]) + ? webauthnDoc["rp_origins"] + : undefined; + const passkey: LegacyPasskeyInput | undefined = passkeyEnabled + ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } + : undefined; + + // Go's `hook.validate()` fixed iteration order (`pkg/config/config.go:1453-1485`) — only + // enabled hooks are forwarded, in that order. `auth.hook..*` is Viper-bound like every + // other nested field (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), so + // `SUPABASE_AUTH_HOOK__ENABLED`/`_URI`/`_SECRETS` overrides apply before this + // validation runs. `@supabase/config`'s hook schema always decodes a `{ enabled: false }` + // default per type regardless of file presence (`packages/config/src/auth/hooks.ts`'s + // `withDecodingDefaultKey`), which erases the presence signal Go's `AutomaticEnv` needs (it + // only intercepts keys already present in the merged config) — so, like the passkey/webauthn + // overrides above, this reads the raw `[auth.hook.]` document instead to gate the + // override on the section actually being present. + const hookDocument = asRecord(authDocument?.["hook"]); + const hooks: Array = []; + for (const hookType of LEGACY_HOOK_TYPE_ORDER) { + const hook = config.auth.hook[hookType]; + const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; + const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; + const hookEnabled = hookSectionPresent + ? legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + hook.enabled, + `auth.hook.${hookType}.enabled`, + projectEnvValues, + ) + : hook.enabled; + if (hookEnabled) { + hooks.push({ + type: hookType, + uri: + (hookSectionPresent + ? envOverride(`${envPrefix}_URI`, hook.uri, projectEnvValues) + : hook.uri) ?? "", + secrets: + (hookSectionPresent + ? envOverride(`${envPrefix}_SECRETS`, hook.secrets, projectEnvValues) + : hook.secrets) ?? "", + }); + } + } + + // Go's `Auth.MFA` factor fields (`TOTP`/`Phone`/`WebAuthn`) are value-typed structs + // (`pkg/config/auth.go:317-320`), never `nil` — unlike `Auth.Hook`'s pointer-typed fields + // above, `ExperimentalBindStruct` always recurses into them (vendored Viper's + // `decodeStructKeys`/`flattenAndMergeMap`) regardless of whether `[auth.mfa.]` is + // present in config.toml (the default template even leaves `[auth.mfa.web_authn]` commented + // out and it's still overridable), so `SUPABASE_AUTH_MFA__{ENROLL,VERIFY}_ENABLED` + // overrides always apply before `Auth.MFA.validate()` runs (`config.go:1523-1534`) — no + // raw-document presence gate needed here, unlike hooks/smtp above. + const mfa: ReadonlyArray = [ + { + label: "totp", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", + config.auth.mfa.totp.enroll_enabled, + "auth.mfa.totp.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", + config.auth.mfa.totp.verify_enabled, + "auth.mfa.totp.verify_enabled", + projectEnvValues, + ), + }, + { + label: "phone", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", + config.auth.mfa.phone.enroll_enabled, + "auth.mfa.phone.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", + config.auth.mfa.phone.verify_enabled, + "auth.mfa.phone.verify_enabled", + projectEnvValues, + ), + }, + { + label: "web_authn", + enrollEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", + config.auth.mfa.web_authn.enroll_enabled, + "auth.mfa.web_authn.enroll_enabled", + projectEnvValues, + ), + verifyEnabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", + config.auth.mfa.web_authn.verify_enabled, + "auth.mfa.web_authn.verify_enabled", + projectEnvValues, + ), + }, + ]; + + // Go's `Config.Validate` runs the email template/notification content read right after + // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read + // stays at this exact textual position (see this function's `@throws` doc for why). + readAuthEmailTemplateContent(config.auth.email, workdir, authDocument, projectEnvValues); + + // Go's `[auth.email.smtp]` presence-based `enabled` default (`pkg/config/config.go:743-748`): + // when the TOML table is present but omits `enabled`, Go treats it as `true` — a genuinely + // presence-based default `@supabase/config`'s schema can't see (it always decodes + // `smtp.enabled` to `false` when the key is absent), so this reads the raw `document` too. + // `auth.email.smtp.*` is Viper-bound like every other nested field once `[auth.email.smtp]` + // is present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), + // so `SUPABASE_AUTH_EMAIL_SMTP_ENABLED`/`_HOST`/`_PORT`/`_USER`/`_PASS`/`_ADMIN_EMAIL` + // overrides apply before `Auth.Email.validate` runs (`config.go:1325-1344`) — layered on top + // of the presence-aware raw-document read above, same `envOverride`/`envOverridePort` + // precedent as every other field in this file. + const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); + const smtp: LegacySmtpInput | undefined = + smtpDoc !== undefined + ? { + enabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", + smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + "auth.email.smtp.enabled", + projectEnvValues, + ), + host: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_HOST", + typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", + projectEnvValues, + ) ?? "", + port: envOverridePort( + "SUPABASE_AUTH_EMAIL_SMTP_PORT", + typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + "auth.email.smtp.port", + projectEnvValues, + ), + user: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_USER", + typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", + projectEnvValues, + ) ?? "", + pass: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_PASS", + typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + projectEnvValues, + ) ?? "", + adminEmail: + envOverride( + "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", + typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", + projectEnvValues, + ) ?? "", + } + : undefined; + + // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) + // — only enabled providers are forwarded, in that order. Like `Auth.MFA` above, each provider + // struct (`tpaFirebase`/`tpaAuth0`/`tpaCognito`/`tpaClerk`/`tpaWorkOs`, `auth.go:191-198`) is + // value-typed, so `SUPABASE_AUTH_THIRD_PARTY__*` overrides always apply — including + // `workos`, whose default template omits `[auth.third_party.workos]` entirely — before + // `Auth.ThirdParty.validate()` runs; no raw-document presence gate needed. + const thirdParty: Array = []; + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "firebase", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "auth0", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ) ?? "", + cognitoUserPoolRegion: envOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "clerk", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ) ?? "", + }); + } + if ( + legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ) + ) { + thirdParty.push({ + provider: "workos", + requiredField: + envOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ) ?? "", + }); + } + + authInput = { + siteUrl: siteUrl ?? "", + captcha: captchaInput, + passkey, + hooks, + mfa, + smtp, + thirdParty, + }; + } + // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` + // key right after the auth block/`generateAPIKeys`, unconditionally. + const functionSlugs = Object.keys(config.functions); + // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth + // block and the functions loop (`pkg/config/config.go:1158-1173`), and — + // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no + // `edge_runtime.enabled` gate. + const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order, each with its own message. + // Backend-enum validation (rejecting a non-postgres/bigquery value) is + // covered at decode time for the `config.toml`-sourced value by + // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), + // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override + // path — see {@link envOverrideAnalyticsBackend} for that case. + const analyticsEnabled = legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); + const gcpProjectId = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); + + // Go's `Config.Validate` calls `c.Experimental.validate()` right after the + // analytics/bigquery block and right before returning. The webhooks check is NOT "the user + // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && + // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` + // isn't explicitly `true`, including one where the key is simply omitted; the section exists + // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML + // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default + // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills + // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the + // TOML section is entirely absent — verified empirically, this default-fill erases exactly the + // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, + // pre-default TOML) instead, same as the passkey/smtp checks above. + const experimentalDocument = asRecord(document?.["experimental"]); + const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is + // deferred to this single call, positioned here (where the last of those checks ran until + // this commit), in Go's exact relative order against every OTHER pure check. This means a + // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers + // first among the ones broken — unchanged from before. The only real reordering risk is + // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls + // cert/key, email template/notification content) that in THIS function's source sits between + // two pure sections (e.g. the signing-keys read sits between the captcha check above and the + // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O + // read now effectively runs BEFORE those later pure checks rather than interleaved at its + // original relative position. This is the same narrow, accepted, documented tradeoff recorded + // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one + // validation failure at a time, so it has zero effect on any real test. + const apiInput: LegacyApiInput = { + enabled: apiEnabled, + port: apiPort, + tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, + }; + const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; + const studioInput: LegacyStudioInput = { + enabled: studioEnabled, + port: studioPort, + apiUrl: studioApiUrl, + }; + const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + }; + const experimentalInput: LegacyExperimentalInput = { + webhooksPresent, + webhooksEnabled, + pgdeltaFormatOptions, + }; + + const input: LegacyConfigValidationInput = { + projectId: resolvedProjectId, + api: apiInput, + db: dbInput, + storageBucketNames, + studio: studioInput, + localSmtp: localSmtpInput, + auth: authInput, + functionSlugs, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + legacyValidateResolvedConfig(input); + // Both run after the single shared `legacyValidateResolvedConfig` call per the module's + // documented sms/external-vs-third_party ordering tradeoff (third_party is checked inside that + // call; sms/external run after it here) — in Go's own relative sms-then-external order + // (`config.go:1145-1150`). `validateAuthSmsProviders` re-runs `@supabase/config`'s schema-level + // switch with env overrides applied (see its doc comment); `validateAuthExternalProviders` is + // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline + // in D") — this is L's port of D's identical inline block. + if (authEnabled) { + validateAuthSmsProviders(authDocument, projectEnvValues); + validateAuthExternalProviders(authDocument, projectEnvValues); + } + + return { + apiUrl: apiExternalUrl, + restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), + graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), + functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), + mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), + studioUrl: `http://${hostname}:${studioPort}`, + mailpitUrl: `http://${hostname}:${mailpitPort}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + publishableKey: resolveOpaqueKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), + projectEnvValues, + ), + defaultPublishableKey, + ), + secretKey: resolveOpaqueKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + projectEnvValues, + ), + defaultSecretKey, + ), + jwtSecret, + anonKey: resolveSignedKey( + decryptAuthSecret( + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + projectEnvValues, + ), + jwtSecret, + signingKey, + "anon", + ), + serviceRoleKey: resolveSignedKey( + decryptAuthSecret( + envOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + config.auth.service_role_key, + projectEnvValues, + ), + projectEnvValues, + ), + jwtSecret, + signingKey, + "service_role", + ), + storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), + storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, + storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, + storageS3Region: DEFAULT_S3_REGION, + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts new file mode 100644 index 0000000000..d3ca495d7c --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -0,0 +1,1716 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { importJWK, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyInvalidAnalyticsBackendEnvOverrideError, + LegacyInvalidBoolEnvOverrideError, + LegacyInvalidJwtSecretError, + LegacyInvalidPortEnvOverrideError, + legacyResolveLocalConfigValues, +} from "./legacy-local-config-values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-local-config-values-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** RSA JWK matching Go's `JWK` struct field names (kty/n/e/d/p/q/dp/dq/qi). */ +function generateRsaJwk(): Record { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; +} + +function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); +} + +describe("legacyResolveLocalConfigValues", () => { + it("derives every URL from api.external_url when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); + expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); + expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); + expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); + expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); + expect(values.studioUrl).toBe("http://127.0.0.1:54323"); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); + }); + + it("uses https and the configured port when api.tls.enabled", () => { + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("uses api.external_url verbatim when configured", () => { + const config = baseConfig({ api: { external_url: "https://example.test" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://example.test"); + expect(values.restUrl).toBe("https://example.test/rest/v1"); + }); + + it("brackets an IPv6 hostname when building host:port", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "::1", WORKDIR); + expect(values.apiUrl).toBe("http://[::1]:54321"); + }); + + it("builds the db URL with the hardcoded postgres password", () => { + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + it("falls back to the default JWT secret and opaque keys when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); + expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); + expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); + }); + + it("uses configured opaque keys verbatim when set", () => { + const config = baseConfig({ + auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("sb_publishable_custom"); + expect(values.secretKey).toBe("sb_secret_custom"); + }); + + it("signs the default anon/service_role JWTs from the resolved secret", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we + // only assert the resolver wires the default secret through to both roles. + const [, anonPayload] = values.anonKey.split("."); + const [, serviceRolePayload] = values.serviceRoleKey.split("."); + expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ + role: "anon", + }); + expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( + { role: "service_role" }, + ); + }); + + it("uses configured anon/service_role keys verbatim when set", () => { + const config = baseConfig({ + auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("signs anon/service_role JWTs from a configured jwt_secret", () => { + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + expect(values.anonKey).not.toBe(""); + }); + + it("rejects a configured jwt_secret shorter than 16 characters", () => { + // Go's Config.Validate fails this at config-load time, before any command + // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown + // error here rather than silently signing with the too-short secret. + const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + describe("encrypted auth secrets", () => { + // Go's test vector (`apps/cli-go/pkg/config/secret_test.go`): this ciphertext + // decrypts to "value" under the keypair below. + const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const VAULT_ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + + afterEach(() => { + delete process.env["DOTENV_PRIVATE_KEY"]; + }); + + it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { + // "value" is only 5 characters, shorter than Go's minimum JWT secret length, + // so pad it out the way a real deployment's decrypted secret would be sized. + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("value"); + }); + + it("fails config loading for an encrypted: secret with no private key, matching Go", () => { + // Go aborts the whole command with `failed to parse config: ` rather + // than silently using the ciphertext as literal key material + // (`secret.go:30-73`, `config.go:704`). + const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { + // Go's decrypt hook runs on whatever value reaches the config.Secret field, + // whether it was sourced from config.toml or a Viper env override. + process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, + }); + expect(values.secretKey).toBe("value"); + delete process.env["DOTENV_PRIVATE_KEY"]; + }); + }); + + it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + // Go's Config.Validate checks ProjectId first, before any other field + // (pkg/config/config.go:990-991). The workdir-basename default is merged + // in as a viper default BEFORE config.toml is merged, so an explicit + // `project_id = ""` in the file overwrites that default with the literal + // empty string rather than being treated as absent — Go fails outright. + const config = baseConfig({ project_id: "" }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { + // Go's `mergeDefaultValues` merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper + // DEFAULT before config.toml is merged (config.go:690-699, via Eject at config.go:561-570) — + // so `c.ProjectId` is never Go's zero value by the time `Validate` runs. A workdir whose + // basename sanitizes to `""` (every character invalid, e.g. `!!!`) therefore still fails + // config loading in Go even with no `project_id` key in the file at all. + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); + + it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { + // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence + // than config.toml (config.go:529-535), so a non-empty env override must + // win even when the file's project_id is explicitly empty. + const config = baseConfig({ project_id: "" }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); + + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + expect(values.storageS3Region).toBe("local"); + }); + + describe("SUPABASE_AUTH_* env overrides", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); + + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. + const ENV_KEYS = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides jwt_secret even when config.toml sets one", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("b".repeat(32)); + }); + + it("overrides publishable_key/secret_key", () => { + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; + process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; + const config = baseConfig({ + auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("env-publishable"); + expect(values.secretKey).toBe("env-secret"); + }); + + it("overrides anon_key/service_role_key", () => { + process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; + process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; + const config = baseConfig({ + auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("env-anon"); + expect(values.serviceRoleKey).toBe("env-service-role"); + }); + + it("treats an empty env var as unset, matching Viper's default", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + }); + + it("still applies the short-secret validation to an env-provided jwt_secret", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + it("overrides signing_keys_path even when config.toml doesn't set one", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + }); + + it("prefers an env-provided signing_keys_path over config.toml's", () => { + const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; + const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; + writeSigningKeys(tempRoot.current, [envJwk]); + const supabaseDir = join(tempRoot.current, "supabase"); + writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [header] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }); + }); + + describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:15-23`) is + // the first mapstructure decode hook composed into `v.UnmarshalExact` + // (`config.go:749-753,769-772`), so it resolves a nested `env(VAR)` + // reference on ANY string mapstructure decodes into the struct — including + // a `SUPABASE_*` env-override value itself, not just a `config.toml` + // literal. `envOverride`'s callers (string/port/bool fields) must all see + // that same resolution. + const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + delete process.env["INDIRECT_JWT_SECRET"]; + delete process.env["INDIRECT_DB_PORT"]; + delete process.env["INDIRECT_API_ENABLED"]; + }); + + it("resolves a string override's env(VAR) indirection", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("c".repeat(32)); + }); + + it("resolves a port override's env(VAR) indirection", () => { + process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; + process.env["INDIRECT_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("resolves a bool override's env(VAR) indirection", () => { + process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; + process.env["INDIRECT_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + // If the bool override weren't resolved through the indirection, the + // literal "env(INDIRECT_API_ENABLED)" string would fail Go's + // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; + // resolving it to "false" disables api.enabled and skips the TLS check + // that would otherwise throw on the missing cert file. + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Go's LoadEnvHook only substitutes when the target var is non-empty + // (`decode_hooks.go:19-24`) — an unset indirection leaves the literal + // `env(VAR)` string, same as an unresolved config.toml-level reference. + expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + }); + }); + + describe("non-auth SUPABASE_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // generically across the whole config struct (pkg/config/config.go:529-535), + // not just auth fields — config_test.go:351,1061 exercise this against + // auth.site_url, and status.go's toValues() reads the already-overridden + // utils.Config.* directly, so every port/URL status derives must honor the + // same override. + const ENV_KEYS = [ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_STUDIO_API_URL", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides db.port for the derived DB URL", () => { + process.env["SUPABASE_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("overrides studio.port for the derived Studio URL", () => { + process.env["SUPABASE_STUDIO_PORT"] = "54330"; + const config = baseConfig({ studio: { port: 54323 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.studioUrl).toBe("http://127.0.0.1:54330"); + }); + + it("overrides local_smtp.port for the derived Mailpit URL", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; + const config = baseConfig({ local_smtp: { port: 54324 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); + }); + + it("overrides api.port for every API-derived URL", () => { + process.env["SUPABASE_API_PORT"] = "54332"; + const config = baseConfig({ api: { port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54332"); + expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); + }); + + it("overrides api.external_url even when config.toml sets one", () => { + process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; + const config = baseConfig({ api: { external_url: "https://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://env-override.example"); + }); + + it("treats an empty non-auth env var as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_PORT"] = ""; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via + // Viper's UnmarshalExact (pkg/config/config.go:749-756, WeaklyTypedInput + // decodes the override string with strconv.ParseUint and hard-fails on a + // malformed value) rather than silently producing a `NaN`-laced URL. + it.each([ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { + process.env[envKey] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + process.env["SUPABASE_DB_PORT"] = "99999"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, + // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a + // Config.Validate-time hard-fail specific to db.port: it has no `enabled` + // gate in Go, unlike api.port/studio.port/local_smtp.port + // (pkg/config/config.go:1006-1009,1031-1032,1070-1073,1081-1084). + it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { + process.env["SUPABASE_DB_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.port", + ); + }); + + // Unlike db.port, Go gates the api.port===0 rejection on api.enabled + // (pkg/config/config.go:1006-1008) — api.enabled defaults to true, so a + // configured or env-overridden zero port is rejected by default. + it("rejects a configured api.port of 0 when api is enabled", () => { + const config = baseConfig({ api: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { + process.env["SUPABASE_API_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("does not reject a zero api.port when api is disabled", () => { + const config = baseConfig({ api: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go gates the studio.port===0 rejection on studio.enabled + // (pkg/config/config.go:1070-1073), same pattern as api.port above. + // studio.enabled defaults to true, so a configured or env-overridden zero + // port is rejected by default. + it("rejects a configured studio.port of 0 when studio is enabled", () => { + const config = baseConfig({ studio: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { + process.env["SUPABASE_STUDIO_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("does not reject a zero studio.port when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's Config.Validate parses studio.api_url with net/url.Parse right + // after the port check, still inside `if c.Studio.Enabled` + // (pkg/config/config.go:1074-1078). + it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + it("does not reject a malformed studio.api_url when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw for the default studio.api_url", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { + process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's + // struct field is still named `Inbucket` for the `[local_smtp]` TOML + // section, pkg/config/config.go:235,1081-1083), same pattern as api.port/ + // studio.port above. local_smtp.enabled defaults to true, so a configured + // or env-overridden zero port is rejected by default. + it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { + const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("db.major_version (required field in config)", () => { + // The pure 0/12/13-17/generic-invalid assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + }); + + it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: 16.", + ); + }); + + it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: abc.", + ); + }); + + it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] + // key right after db.major_version, unconditionally — there is no + // storage.enabled-style gate (pkg/config/config.go:1063-1068). + // + // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — this section has no L-specific derivation or env-override mechanics of its own. + + // Go's Config.Validate rejects an invalid edge_runtime.deno_version + // unconditionally — NOT gated on edge_runtime.enabled + // (pkg/config/config.go:1164-1173). + describe("edge_runtime.deno_version (required field in config)", () => { + // The pure 0/1/2/generic-invalid/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + }); + + it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); + + it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + }); + + it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); + + it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("analytics (BigQuery backend required fields)", () => { + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order. + // + // The pure required-field/complete/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_ANALYTICS_ENABLED"]; + delete process.env["SUPABASE_ANALYTICS_BACKEND"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + }); + + it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("accepts env-provided GCP fields overriding empty config.toml values", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; + process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects any + // `analytics.backend` value outside `postgres`/`bigquery` during the same + // `UnmarshalExact` decode every `SUPABASE_*` override goes through + // (`config.go:749-756`) — a malformed `SUPABASE_ANALYTICS_BACKEND` fails + // config loading outright, same mechanism as the port/bool overrides below. + it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidAnalyticsBackendEnvOverrideError, + ); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + ); + }); + }); + + describe("experimental.* (experimental.validate())", () => { + // Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), + // called right after the analytics/bigquery block and right before + // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. + // + // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks + // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of + // deriving them from a raw `document`) — only this document-THREADING-specific case stays + // here, since it exercises this function's own "no document provided" fallback rather than + // a check `legacyValidateResolvedConfig` itself owns. + it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { + // No `document` (5th param) at all — e.g. a caller that hasn't threaded + // `LoadedProjectConfig.document` through yet. The presence-only check + // can't run without it, so it's skipped rather than guessed at; this + // also covers every pre-existing call site/test in this file that + // doesn't pass a 5th argument. + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("SUPABASE_API_TLS_ENABLED env override", () => { + // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) + // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), + // so an ambient/dotenv override flips http/https even when config.toml says + // otherwise. + afterEach(() => { + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("overrides api.tls.enabled from false to true", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("overrides api.tls.enabled from true to false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "false"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); + + it("does not override api.tls.enabled once api.external_url is set", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { external_url: "http://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://config.example"); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + + it("treats an empty override as unset, matching Viper's default", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = ""; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + }); + + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + + it("signs anon/service_role with the first RS256 key in the file", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + + const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }); + + it("resolves a relative signing_keys_path against /supabase", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("still prefers an explicit anon_key/service_role_key over signing keys", () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { + writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); + + it("throws a Go-worded error when the signing keys file does not exist", () => { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("throws a Go-worded error when the signing keys file is malformed JSON", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to decode signing keys: ", + ); + }); + + it("throws when the first key uses an unsupported algorithm", () => { + writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "unsupported algorithm: RS512", + ); + }); + + // Go's `Validate` only opens/parses `signing_keys_path` inside + // `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`) — a disabled + // auth section never touches the file, however stale or missing it is. + it("skips reading a missing signing_keys_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("skips reading a malformed signing_keys_path when auth is disabled", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Falls back to HMAC signing, matching an absent signing key. + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); + + describe("SUPABASE_AUTH_ENABLED env override", () => { + // `c.Auth.Enabled` is Viper-bound like any other field + // (config.go:582-586), so `Validate`'s `if c.Auth.Enabled` gate + // (config.go:1036,1059-1065) reads the POST-override value, not raw + // TOML — a stale/missing signing_keys_path must be skipped when auth is + // disabled only via env/dotenv, and read when auth is enabled only via + // env/dotenv despite TOML saying otherwise. + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ + auth: { enabled: true, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + }); + }); + + describe("auth.site_url (required field in config)", () => { + // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / + // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. + describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + delete process.env["SUPABASE_AUTH_SITE_URL"]; + }); + + it("rejects an empty site_url when auth is enabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.site_url", + ); + }); + + it("does not throw when auth is disabled only via env, however empty site_url is", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("accepts an env-provided site_url overriding an empty config.toml value", () => { + process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + }); + + // auth.captcha/passkey/webauthn/hook/smtp REQUIRED-FIELD checks (the actual `enabled` ⇒ + // provider/secret/uri/host/etc. logic) live entirely in `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls). Only the SUPABASE_*-env-override MECHANICS + // this resolver owns — layering an env/dotenv value on top of the TOML-decoded or + // raw-document-derived value before that validation ever runs — are tested here, same split as + // `auth.site_url` above. + + describe("auth.captcha env overrides", () => { + // `auth.captcha.*` is Viper-bound like any other nested field once `[auth.captcha]` is + // present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`). + afterEach(() => { + delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; + delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; + delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; + }); + + it("rejects a captcha section enabled only via env with no provider", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + const config = baseConfig({ auth: { captcha: { enabled: false } } }); + const document = { auth: { captcha: { enabled: false } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("accepts env-provided provider/secret overriding an enabled captcha section", () => { + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.passkey / auth.webauthn env overrides", () => { + // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like any other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml. Both are read from the raw + // `document` (5th param), same as the presence-based defaulting above, so these tests thread + // a `document` object through explicitly instead of relying on `baseConfig`'s decoded schema + // (which has no `passkey`/`webauthn` fields at all). + afterEach(() => { + delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + }); + + it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = + "http://localhost:3000,http://localhost:3001"; + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { + process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.hook.* env overrides", () => { + // `auth.hook..*` is Viper-bound like any other nested field once `[auth.hook.]` + // is present in config.toml. `@supabase/config`'s hook schema always decodes a default + // `{ enabled: false }` regardless of file presence, so — like passkey/webauthn above — the + // presence gate is read from the raw `document`, not the decoded `config`. + afterEach(() => { + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; + delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; + }); + + it("rejects a hook section enabled only via env with no uri", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { hook: { send_email: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.hook.send_email.uri"); + }); + + it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; + const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); + const document = { auth: { hook: { send_email: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.email.smtp env overrides", () => { + // `auth.email.smtp.*` is Viper-bound like any other nested field once `[auth.email.smtp]` + // is present in config.toml — layered on top of the presence-aware raw-document read that + // already exists here for Go's presence-based `enabled` default. + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + }); + + it("rejects an smtp section enabled only via env with no host", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(LegacyInvalidPortEnvOverrideError); + }); + + it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.mfa env overrides", () => { + // `auth.mfa..*` is Viper-bound unconditionally (value-typed struct fields, never + // `nil`) — unlike hooks/smtp above, no raw-document presence gate is needed; see the block + // comment above the `mfa` array in legacy-local-config-values.ts. + afterEach(() => { + delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; + }); + + it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + ); + }); + + it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; + process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + }); + + describe("auth.third_party env overrides", () => { + // Same value-typed-struct reasoning as auth.mfa above — including `workos`, whose default + // template omits `[auth.third_party.workos]` entirely yet is still unconditionally overridable. + afterEach(() => { + delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; + delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; + }); + + it("rejects a third-party provider enabled only via env with no required field configured", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }); + + it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not enable a third-party provider purely from a required-field env override", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.email.template/notification (content_path validation)", () => { + // Go's `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`), + // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + const tempRoot = useLegacyTempWorkdir("supabase-email-templates-test-"); + + it("rejects a template content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a template with no content_path configured", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects an enabled notification content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("resolves a relative notification content_path against /supabase", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a disabled notification's missing content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a missing template content_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric + // content-vs-content_path exclusivity (`config.go:1293-1313`) — a raw `content` key present + // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no + // `content` field to see, so this only fires when the raw `document` (5th param) carries it. + it("rejects a template content key present without content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + ).toThrow( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }); + }); + + describe("auth.email.template/notification env overrides", () => { + // `auth.email.template..*`/`auth.email.notification..*` are Viper-bound like any + // other nested field once the section is present in config.toml. Unlike hook/passkey, no + // extra raw-document presence gate is needed: `email.template`/`email.notification` are + // `Schema.Record`s, so `Object.entries` on the decoded config already reflects presence. + const tempRoot = useLegacyTempWorkdir("supabase-email-template-env-test-"); + + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; + delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; + }); + + it("lets an env-provided template content_path override a missing TOML content_path", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects a notification enabled only via env with a missing content_path file", () => { + // Go applies SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED before + // Auth.Email.validate() decides whether to read content_path — a notification disabled + // in TOML but enabled by env must still be checked. + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: false, content_path: "missing.html" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("lets an env-provided notification content_path override a missing TOML content_path", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "pw-changed.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: true } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + }); + + // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) + // moved entirely to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — L pre-filters to enabled-only third_party providers and derives function slugs + // directly off `config.functions` with no env-override mechanics of its own for these checks. + + describe("auth.external (external.validate(), D-only, ported to L)", () => { + // `auth.external` is a genuine Go `map[string]provider`, so an unmodeled/arbitrary provider + // name is a legitimate config shape `@supabase/config`'s schema silently drops at decode — + // this check reads the raw `document` (5th param) instead, same as passkey/hook above. + it("rejects an enabled unmodeled external provider missing client_id", () => { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.client_id"); + }); + + it("rejects an enabled unmodeled external provider missing secret", () => { + const config = baseConfig(); + const document = { + auth: { external: { custom: { enabled: true, client_id: "abc" } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.secret"); + }); + + it("does not require a secret for apple/google providers", () => { + const config = baseConfig(); + const document = { + auth: { external: { apple: { enabled: true, client_id: "abc" } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("skips deprecated linkedin/slack providers", () => { + const config = baseConfig(); + const document = { auth: { external: { slack: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not validate a disabled unmodeled external provider", () => { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("skips the check entirely when no document is threaded through", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.external env overrides", () => { + // `auth.external..*` is Viper-bound like any other nested field once + // `[auth.external.]` is present in config.toml — same gap the schema's own + // `requiredWhenEnabled` check has for KNOWN providers too. + afterEach(() => { + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; + delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; + }); + + it("rejects a provider enabled only via env with no client_id", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.external.custom.client_id"); + }); + + it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("auth.sms env overrides (provider switch)", () => { + // Go's `(s *sms) validate()` (`pkg/config/config.go:1348-1410`) is a `switch` that validates + // ONLY the first enabled provider in a fixed priority order (twilio, twilio_verify, + // messagebird, textlocal, vonage). `@supabase/config`'s schema already implements this switch + // for the schema-decoded (pre-env-override) TOML value; this re-runs it against the raw + // document with `SUPABASE_AUTH_SMS_*` overrides applied, since the schema never sees them. + afterEach(() => { + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; + delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; + delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; + }); + + it("rejects a provider enabled only via env with missing required fields", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + const config = baseConfig(); + const document = { auth: { sms: { twilio: { enabled: false } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); + }); + + it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; + process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; + process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; + const config = baseConfig(); + const document = { auth: { sms: { twilio: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("only validates the first enabled provider in Go's fixed priority order", () => { + // twilio is disabled via env; messagebird becomes the switch winner and is missing its + // required fields — twilio's own (still-missing) fields must never be inspected. + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + const config = baseConfig(); + const document = { + auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); + }); + + it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("api.tls (cert/key validation)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); + + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } + + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // The "exactly one of cert/key set" presence-only assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // the actual file reads below stay here, since I/O is per-caller. + + it("throws a Go-worded error when the configured cert file does not exist", () => { + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + + it("throws a Go-worded error when the configured key file does not exist", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); + + it("succeeds when both cert_path and key_path are readable", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` + // (config.go:1006,1010) — a disabled api section never validates cert/key, + // however invalid the pairing. + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_API_ENABLED"]; + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("skips TLS validation when api is disabled only via env", () => { + process.env["SUPABASE_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts index 9b0ab6b18d..2b75bcd08a 100644 --- a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts @@ -15,6 +15,7 @@ import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { LegacyProjectRefResolver } from "../config/legacy-project-ref.service.ts"; import { legacyProjectRefLayer } from "../config/legacy-project-ref.layer.ts"; +import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; import { legacyDohFetchLayer } from "./legacy-http-dns.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer } from "./legacy-identity-stitch.ts"; @@ -104,6 +105,10 @@ export function legacyManagementApiRuntimeLayer(subcommand: ReadonlyArray { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, * `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the `BEGIN`/`COMMIT` - * that wraps a migration, so `legacyApplyMigrationFile` runs them standalone. + * that wraps a migration, so `execMigrationBatch` runs them standalone. * Port of Go's `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). */ export const legacyIsPipelineIncompatible = (sql: string): boolean => { @@ -79,55 +80,45 @@ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } | { readonly kind: "version" }; +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + /** - * Applies a single migration file to the connected database and records it in - * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` - * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` - * first to clear any session state leaked by a prior file, then create the history - * table, then run the file's statements + the history insert. - * - * Statements run inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones + * Runs a single migration/seed file's statements (plus the optional history insert). + * Mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`): statements run + * inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones * (`legacyIsPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which - * cannot run in a transaction block: the batch is flushed (committed), the statement - * runs standalone, then batching resumes — mirroring Go's `ExecBatch` flush logic - * (supabase/cli#5156). The history insert goes in the final batch, so the migration - * is recorded only after every statement succeeds. A file with no such statements is - * a single `BEGIN`/`COMMIT` around everything, identical to the pre-fix behaviour. + * cannot run in a transaction block: the open batch is flushed (committed), the + * statement runs standalone, then batching resumes (supabase/cli#5156). The history + * insert goes in the final batch, so the migration is recorded only after every + * statement succeeds. A file with no such statements is a single `BEGIN`/`COMMIT`. * - * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + * Does NOT create the history table and does NOT `RESET ALL` — Go's `ExecBatch` does + * neither; those are the migration-apply path's responsibility (`ApplyMigrations`, + * apply.go:65-69), so role/globals files (`legacySeedGlobals`) stay reset-free like Go. + * When `forceNoVersion` is set the history insert is skipped regardless of filename + * (Go's `SeedGlobals` clears `Version`). */ -export const legacyApplyMigrationFile = ( +const execMigrationBatch = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, mapError: (message: string) => E, + forceNoVersion: boolean, ): Effect.Effect => Effect.gen(function* () { const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); const filename = path.basename(migrationPath); const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = matches?.[1] ?? ""; + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; - // `RESET ALL` runs FIRST, before the history-table DDL: an earlier migration applied - // on this same connection may have left a session default (e.g. - // `SET default_transaction_read_only = on`) that would otherwise make this DDL fail - // before it is cleared. Go resets connection state at the top of each file's apply, - // ahead of any work (`apps/cli-go/pkg/migration/apply.go:65-69`). - yield* session.exec("RESET ALL"); - yield* legacyCreateMigrationTable(session); - // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go`): - // on a failed statement, append `At statement: ` and the statement text so the - // error (and the debug bundle) point at the exact failing SQL. (Go also adds a caret / - // pgErr.Detail / extension-type hint, which need the driver SQLSTATE the session does - // not currently surface — the statement number + text is the always-present context.) - const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); + // on a failed statement, append `At statement: ` and the statement text. const atStatement = (e: unknown, index: number, stat: string) => new Error(`${errMessage(e)}\nAt statement: ${index}\n${stat}`); @@ -183,10 +174,91 @@ export const legacyApplyMigrationFile = ( pending = [...pending, { kind: "version" }]; } yield* flushBatch; - }).pipe( - Effect.mapError((error) => - mapError( - "message" in error && typeof error.message === "string" ? error.message : String(error), - ), - ), - ); + }).pipe(Effect.mapError((error) => mapError(errMessage(error)))); + +/** + * Go's per-migration connection reset (`apply.go:65-69`): `RESET ALL` clears any + * connection settings a prior statement on the same session may have changed + * (e.g. `set_config('search_path', …)`), run before each migration's `ExecBatch`. + * Only the migration-apply path does this — `SeedGlobals` (role/globals files) + * must NOT, so this is a caller responsibility, never inside `execMigrationBatch`. + */ +const resetConnectionState = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + session.exec("RESET ALL").pipe(Effect.mapError((e) => mapError(errMessage(e)))); + +/** + * Applies a single migration file to the connected database and records it in + * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` + * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` + * first to clear any session state leaked by a prior file (e.g. + * `SET default_transaction_read_only = on`) before the history-table DDL, then create + * the history table, then run the file's statements + the history insert. + * + * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + */ +export const legacyApplyMigrationFile = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + migrationPath: string, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + yield* resetConnectionState(session, mapError); + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + }); + +/** + * Applies a list of pending migration files, mirroring Go's + * `migration.ApplyMigrations` (`pkg/migration/apply.go:56-77`): create the + * history table once when there is anything to apply, then for each file emit + * `Applying migration ...` to stderr, `RESET ALL`, and run it transactionally. + */ +export const legacyApplyMigrations = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + pending: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (pending.length === 0) return; + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + for (const migrationPath of pending) { + yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); + // Go resets connection state per migration (apply.go:65-69) before ExecBatch. + yield* resetConnectionState(session, mapError); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + } + }); + +/** + * Applies custom-role / globals files, mirroring Go's `migration.SeedGlobals` + * (`pkg/migration/seed.go:85-100`): for each file emit `Seeding globals from + * ...` to stderr and run it transactionally WITHOUT inserting a migration + * history row (Go clears `Version`), WITHOUT creating the history table, and WITHOUT + * `RESET ALL` (Go's `SeedGlobals` → `ExecBatch` never resets). + */ +export const legacySeedGlobals = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + globals: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + for (const globalPath of globals) { + yield* output.raw(`Seeding globals from ${path.basename(globalPath)}...\n`, "stderr"); + yield* execMigrationBatch(session, fs, path, globalPath, mapError, true); + } + }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 567be21e75..a5039843de 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -5,10 +5,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, + legacySeedGlobals, } from "./legacy-migration-apply.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} @@ -230,3 +232,27 @@ describe("legacyIsPipelineIncompatible", () => { expect(legacyIsPipelineIncompatible(sql)).toBe(want); }); }); + +describe("legacySeedGlobals", () => { + it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const file = join(dir, "roles.sql"); + writeFileSync(file, "CREATE ROLE my_role;"); + const { session, calls } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + const execs = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + // Go's SeedGlobals calls ExecBatch directly — no RESET ALL (that's only the + // migration-apply path) and no schema-migrations history insert. + expect(execs).not.toContain("RESET ALL"); + expect(execs).toContain("CREATE ROLE my_role"); + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts new file mode 100644 index 0000000000..fe1ad08291 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; + +import { parseDotEnv } from "./legacy-dotenv.ts"; + +/** + * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's + * `loadNestedEnv` (`apps/cli-go/pkg/config/config.go:1169-1190`). Go's version + * walks not just `supabase/` but one directory further, up to the project + * root/workdir (the loop stops once `cwd == filepath.Dir(repoDir)`, i.e. after + * exactly two directories: `supabase/`, then its parent), and at each + * directory calls `loadDefaultEnv` (`config.go:1192-1207`), which loads dotenv + * files chosen by `SUPABASE_ENV` (empty/unset defaults to `"development"`, + * `config.go:1193-1195`): `.env..local`, `.env.local` (skipped when + * `env === "test"`), `.env.`, `.env` — via `godotenv.Load`, which only + * sets a key if it isn't already present in the process environment + * (`godotenv@v1.5.1/godotenv.go:184-204`, `overload: false`). Because + * `godotenv.Load` writes straight into the process env as it goes, the net + * precedence (highest first) is: ambient shell env > `supabase/`-dir dotenv + * files (`.local` variant before non-local, env-specific before bare `.env`) + * > project-root dotenv files (same internal order). + * + * `loadProjectEnvironment` only implements the `supabase/`-dir, plain + * `.env`/`.env.local` half of this (no project-root pass, no `SUPABASE_ENV` + * filename selection) — and it's shared infrastructure used well beyond + * `legacy/` (the `next/` command tree, `secrets set`), so extending its + * file-resolution semantics is out of scope for a `stop`/`status` port. + * Instead, this fills in the missing project-root + `SUPABASE_ENV`-selected + * files locally: `loadProjectEnvironment`'s already-resolved `values` (its + * ambient-wins-over-`supabase/.env`(.local) result) always takes precedence + * over anything discovered here, since it's already correct for the keys it + * knows about. + */ +function candidateDotenvFilenames(env: string): ReadonlyArray { + return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; +} + +/** + * Minimal dotenv reader for the project-root and `SUPABASE_ENV`-selected extra + * files this module resolves, intentionally not reusing `@supabase/config`'s + * Effect-based `FileSystem` parser: this module stays a plain synchronous + * helper (like `legacy-local-config-values.ts`'s `loadFirstSigningKey`) since + * it only needs a handful of extra files read once per `stop`/`status` + * invocation. Delegates to {@link parseDotEnv} — the same `godotenv`-faithful, + * cursor-based parser `bootstrap`/`legacyReadDbToml` already use — rather than + * a hand-rolled line-by-line scan, so a quoted value spanning physical lines + * (a PEM/private key) parses correctly instead of aborting on what looks like + * a malformed continuation line. + * + * @throws on a line that isn't blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` + * assignment — matching Go's `loadEnvIfExists` (`pkg/config/config.go:1209-1234`), + * which propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and + * fails `Config.Load` before `stop`/`status` touch Docker, rather than silently + * skipping the bad line. + */ +function readDotEnvFile(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + + const contents = readFileSync(path, "utf8"); + try { + return parseDotEnv(contents); + } catch (cause) { + throw new Error( + `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + ); + } +} + +/** + * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` + * overrides (project id, auth fields) from — the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover, layered + * under only the truly ambient-sourced entries of `projectEnv.values`. + * + * Only `projectEnv`'s AMBIENT entries outrank `merged`: `projectEnv.values` + * also carries plain `supabase/.env`/`.env.local` values it read itself, and + * those are not necessarily higher Go precedence than an env-specific file + * (`.env..local`/`.env.`) `merged` resolved — `loadProjectEnvironment` + * has no notion of `SUPABASE_ENV`-selected filenames, so it can't tell the two + * apart itself. `merged`'s own walk below already re-derives the full file + * precedence, including `supabase/.env`(.local), so only ambient needs to be + * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly + * those entries — see `loadProjectEnvironment`'s `ProjectEnvironment` shape). + * + * `projectEnv` is `null` whenever `@supabase/config` found no + * `supabase/config.toml`/`config.json` (searching ancestors, or at exactly + * `workdir` when the caller passed `search: false`) — but Go's dotenv loading + * doesn't share that precondition: `Config.Load` calls + * `loadNestedEnv(builder.SupabaseDirPath)` BEFORE it ever opens `config.toml` + * (`pkg/config/config.go:786-793`), and `SupabaseDirPath` is a pure string + * join with no existence check (`NewPathBuilder`, `pkg/config/utils.go:43-48`). + * So a missing/absent config file must not skip dotenv loading — fall back to + * deriving the same two directories directly from `workdir` + * (`/supabase` and `workdir` itself) and read `process.env` itself as + * the ambient layer, since there's no `loadProjectEnvironment` result to + * consult for it in this branch. + */ +export function legacyResolveProjectEnvironmentValues( + projectEnv: ProjectEnvironment | null, + workdir: string, +): Record { + const env = process.env["SUPABASE_ENV"] || "development"; + const filenames = candidateDotenvFilenames(env); + const merged: Record = {}; + + const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); + const projectRoot = projectEnv?.paths.projectRoot ?? workdir; + + // supabase/ dir first, then its parent (the project root) — matching Go's + // directory walk order. Within a directory, `godotenv.Load`'s "never + // override an already-set var" means first-processed-wins, so the plain + // merge below (skip keys already present) reproduces both orderings at once. + for (const dir of [supabaseDir, projectRoot]) { + for (const filename of filenames) { + const parsed = readDotEnvFile(join(dir, filename)); + if (parsed === undefined) continue; + for (const [key, value] of Object.entries(parsed)) { + if (!(key in merged)) merged[key] = value; + } + } + } + + const ambientOverrides: Record = {}; + if (projectEnv !== null) { + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + } else { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + ambientOverrides[key] = value; + } + } + } + + return { ...merged, ...ambientOverrides }; +} diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts new file mode 100644 index 0000000000..8e7e71b826 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -0,0 +1,300 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; + +let root: string; +let supabaseDir: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-legacy-project-env-")); + supabaseDir = join(root, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env["SUPABASE_ENV"]; + delete process.env["SUPABASE_PROJECT_ID"]; +}); + +function fakeProjectEnv( + values: Record = {}, + sources: Record = {}, +): ProjectEnvironment { + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + envPath: join(supabaseDir, ".env"), + envLocalPath: join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + // Default every given value to "ambient" unless the caller says otherwise — + // matches how most tests use this helper (representing an already-resolved, + // highest-precedence value) without forcing every call site to spell it out. + sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), + }; +} + +describe("legacyResolveProjectEnvironmentValues", () => { + it("returns just the already-loaded values when no extra dotenv files exist", () => { + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); + expect(legacyResolveProjectEnvironmentValues(projectEnv, root)).toEqual({ + SUPABASE_PROJECT_ID: "from-loader", + }); + }); + + it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }); + + it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets already-resolved projectEnv.values win over anything discovered locally", () => { + // `projectEnv.values` already reflects loadProjectEnvironment's correct + // ambient-wins-over-supabase/.env(.local) result; a redundant root .env + // entry for the same key must never override it. + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("defaults SUPABASE_ENV to development when unset", () => { + writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }); + + it("selects the SUPABASE_ENV-named file over the bare .env file", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }); + + it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }); + + it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { + process.env["SUPABASE_ENV"] = "test"; + writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); + writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }); + + it("strips quotes the same way the shared dotenv parser does", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }); + + it("ignores blank lines and comments", () => { + writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }); + + it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { + // godotenv only starts an inline comment at a `#` preceded by whitespace + // (`godotenv@v1.5.1/parser.go:144-153`); `foo#bar` keeps the `#` verbatim. + writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }); + + it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); + }); + + it("strips a trailing comment after a quoted value, matching godotenv", () => { + // godotenv's `extractVarValue` locates the quoted span by scanning forward for the + // closing quote (`godotenv@v1.5.1/parser.go:160-180`) and discards anything after + // it as a comment — the value is `demo`, not the literal `"demo"` a check that + // requires the whole trimmed remainder to end with a quote would produce. + writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { + // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators + // (`godotenv@v1.5.1/parser.go:90-95`), and the repo's other dotenv parser + // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + }); + + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { + // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so + // a key it resolved from a plain supabase/.env file is NOT necessarily + // higher Go precedence than a same-named key from `.env..local` — + // only an "ambient" source outranks the file precedence computed locally. + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }); + + it("still lets a truly ambient-sourced value win over any file", () => { + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { + writeFileSync(join(root, ".env"), "not a valid line\n"); + expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( + /failed to parse environment file/, + ); + }); + + it("expands an unquoted $VAR reference to an earlier value in the same file", () => { + // godotenv expands unquoted/double-quoted references while loading + // (`godotenv@v1.5.1/parser.go:157`), so a later key can reuse an earlier one. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("expands a braced ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }); + + it("does not expand variable references inside single-quoted values", () => { + // godotenv never calls expandVariables for single-quoted values + // (`parser.go:172-173`) — they stay byte-literal. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }); + + it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }); + + it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }); + + it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { + // godotenv's expandVarRegex captures a leading backslash and strips ONLY + // that backslash, returning the rest of the match verbatim instead of + // doing a lookup (`godotenv@v1.5.1/parser.go:253,264-265`) — even when + // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become + // `demodemo`. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }); + + it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }); + + it("treats a bare trailing $ with no variable name as a literal", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }); + + it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { + // godotenv's parser scans the whole buffer with a cursor, not line-by-line + // (`godotenv@v1.5.1/parser.go:20-45`), so a quoted value spanning physical + // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest + // of the file. A naive line-by-line reader would see the continuation line + // as malformed and abort before SUPABASE_PROJECT_ID is ever read. + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + writeFileSync( + join(root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }); + + describe("when no project was found (projectEnv is null)", () => { + // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`), so a missing config file must + // not skip dotenv loading — these cover the local fallback that derives + // `/supabase`/`workdir` directly instead of giving up. + + it("still reads a supabase/-dir dotenv file directly under workdir", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }); + + it("still reads a project-root dotenv file directly under workdir", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }); + + it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { + process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }); + + it("returns an empty object when workdir has no dotenv files and no ambient value", () => { + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 64ba6368a6..81499e2b72 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -29,6 +29,9 @@ export const legacyParseYesNo = (input: string): boolean | undefined => { * `db pull`, `seed buckets`, and `storage rm`: * - when `yes` is set, echoes `