diff --git a/.github/scripts/ai-review/post-review.test.ts b/.github/scripts/ai-review/post-review.test.ts index cd1db8890a..0f884b196c 100644 --- a/.github/scripts/ai-review/post-review.test.ts +++ b/.github/scripts/ai-review/post-review.test.ts @@ -25,9 +25,7 @@ import { truncateReviewBody, } from "./post-review.ts"; -// A single hunk touching file.ts lines 10-14 on the new side: line 10 is -// context, line 11 replaces a removed line, 12 is a pure addition, 13-14 are -// trailing context. Hand-computed RIGHT-side anchors: {10, 11, 12, 13, 14}. +// One hunk on file.ts; RIGHT-side anchors: {10, 11, 12, 13, 14}. const SINGLE_HUNK_DIFF = `diff --git a/file.ts b/file.ts index 111..222 100644 --- a/file.ts @@ -41,8 +39,7 @@ index 111..222 100644 context line 14 `; -// Two hunks in the same file: {1,2,3} from the first hunk, {20,21,22} from -// the second (the RIGHT counter resets to each hunk's own header). +// Two hunks in one file; each resets the RIGHT counter to its own header. const MULTI_HUNK_DIFF = `diff --git a/multi.ts b/multi.ts index 1..2 100644 --- a/multi.ts @@ -101,8 +98,7 @@ index 0..6 +line three `; -// A trailing "\ No newline at end of file" marker on both sides must not -// perturb the RIGHT counter: anchors are still {1,2}. +// A trailing "\ No newline at end of file" marker must not perturb the RIGHT counter. const NO_NEWLINE_DIFF = `diff --git a/nonewline.ts b/nonewline.ts index 7..8 100644 --- a/nonewline.ts @@ -115,9 +111,7 @@ index 7..8 100644 \\ No newline at end of file `; -// git appends a literal TAB after a `+++` path that needs quoting (here, -// because it contains a space); the tab must be stripped so anchors key on -// "has space.ts", not "has space.ts\t". +// git appends a literal TAB after a quoted `+++` path; it must be stripped. const TAB_PATH_DIFF = `diff --git a/has space.ts b/has space.ts index 9..a 100644 --- a/has space.ts @@ -127,10 +121,7 @@ index 9..a 100644 +added line `; -// A pure rename (100% similarity) carries no `---`/`+++`/`@@` lines at all, -// followed by a normal file's diff — the parser must not leak state (e.g. a -// leftover `currentFile`) from the header-less rename section into the next -// file. +// A pure rename carries no `---`/`+++`/`@@` lines; state must not leak into the next file's diff. const RENAME_ONLY_THEN_NORMAL_DIFF = `diff --git a/old-name.ts b/new-name.ts similarity index 100% rename from old-name.ts @@ -144,10 +135,7 @@ index 1..2 100644 +added `; -// An added line whose literal content is "++ b/not-a-real-header.ts" appears -// in the diff, prefixed by the diff's own "+", as "+++ b/not-a-real-header.ts" -// — a `+++`-lookalike that must not hijack `currentFile` because it occurs -// inside a hunk, not between a `diff --git` boundary and the first `@@`. +// An added line whose content looks like a `+++` header must not hijack `currentFile`. const PLUS_LOOKALIKE_DIFF = `diff --git a/lookalike.ts b/lookalike.ts index 1..2 100644 --- a/lookalike.ts @@ -828,10 +816,6 @@ describe("buildReviewPayload", () => { }); test("truncates the very first payload's body when it already exceeds the cap with zero comments to fold", () => { - // Not anchorable (line 999 is outside the diff hunk), so this produces a - // body-only payload with no inline comments — the 422-retry fold path - // never runs, so only truncating `buildReviewPayload`'s own body catches - // an oversized initial POST. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const payload = buildReviewPayload(review, anchors, footer); @@ -1045,10 +1029,7 @@ describe("post flow via injected ReviewIo", () => { if (opts.failSupersede) { return Promise.reject(new Error("listReviews failed")); } - // Mirror real GitHub: a review posted earlier in the same run shows - // up in later listings as a marker-bearing bot review. The supersede - // pass must snapshot BEFORE posting or it would wrap the fresh - // review as "superseded" too. + // Mirrors real GitHub: a review posted earlier in the same run shows up in later listings. const alreadyPosted = postedReviews.map((payload, i) => ({ id: 900 + i, body: payload.body, @@ -1141,9 +1122,6 @@ describe("post flow via injected ReviewIo", () => { await postConsolidatedReview(io, 42, review, footer); - // With no prior AI review on the PR, nothing may be wrapped as superseded - // — especially not the review this run just posted (which the fake's - // listReviews, like real GitHub, includes in post-POST listings). expect(postedReviews).toHaveLength(1); expect(updatedReviews).toEqual([]); expect(updatedComments).toEqual([]); @@ -1230,8 +1208,6 @@ describe("post flow via injected ReviewIo", () => { }); test("posts a truncated body on the very first attempt for an oversized body-only review (no comments to fold)", async () => { - // Not anchorable, so there's no inline comment for GitHub to 422 on — the - // old behavior threw here instead of posting a truncated body. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const { io, postedReviews } = makeReviewIo({ diff: SINGLE_HUNK_DIFF }); diff --git a/.github/scripts/ai-review/post-review.ts b/.github/scripts/ai-review/post-review.ts index c56949d1d3..93a042ed44 100644 --- a/.github/scripts/ai-review/post-review.ts +++ b/.github/scripts/ai-review/post-review.ts @@ -1,56 +1,38 @@ /** - * AI review poster: validates the structured findings both model passes - * produce, and posts the ONE consolidated PR review the pipeline is allowed - * to post per run. + * AI review poster: validates the structured findings the two model passes + * produce, and posts the one consolidated PR review this pipeline posts per run. * - * Four subcommands, dispatched from `argv`: - * - `validate-findings ` — checks a Claude findings JSON file against - * the shape `.github/ai-review/findings.schema.json` describes. The - * `--json-schema` flag passed to `claude` is a hint to the model, not a - * runtime guarantee, so the CI step re-checks the extracted output here - * before it is trusted. - * - `validate-merged ` — same idea for the Codex-adjudicated merged - * review, against `.github/ai-review/merged-review.schema.json`. - * - `redact ` — reads a JSON file, deep-walks every string value - * through `redactSecrets`, and writes it back in place. Run on every - * model-output JSON file before it's uploaded as a (public-repo) - * artifact, so a prompt-injected `Read` of a secret-bearing path can't - * smuggle a credential out through the artifact even though the posted - * review is already scrubbed at render time. - * - `post` — snapshots the PR's prior AI reviews, posts the consolidated - * review, THEN best-effort supersedes the snapshotted ones (the - * marker/dedup guard in `resolve.ts` should normally prevent a second - * run, but `/ai-review` lets a maintainer force one). The snapshot must - * happen BEFORE the POST — the fresh review is itself a marker-bearing - * bot review, so a post-hoc listing would sweep it into its own - * supersede pass and every new review would collapse itself. Posting - * before superseding, and treating both the snapshot and the supersede - * as best-effort, means a cosmetic failure can never cost the real - * review. + * Subcommands, dispatched from `argv`: + * - `validate-findings ` / `validate-merged ` — check a model's + * findings/merged-review JSON against its schema under `.github/ai-review/`. + * The `--json-schema` flag passed to `claude` is only a hint to the model, + * so this re-validates its output before it's trusted. + * - `redact ` — scrubs secret-shaped strings from a JSON file in + * place; run on every model-output file before it's uploaded as a + * (public-repo) artifact. + * - `post` — builds and posts the consolidated review, then supersedes any + * prior AI reviews on the PR (see `listPriorRunsBestEffort` for why the + * snapshot must happen before the POST). * * `parseDiffAnchors`, `partitionFindings`, `renderReviewBody`, * `renderInlineComment`, `buildReviewPayload`, `foldInlineCommentsIntoBody`, * `supersededBody`, `isSuperseded`, `sanitizeFilePath`, and `redactSecrets` - * are pure and exported for tests. `postConsolidatedReview` is the I/O - * orchestration function for the `post` subcommand; it's exported so a test can drive it against - * an injected `ReviewIo` fake without the network, the same way - * `resolveDecision` is tested in `resolve.ts`. `main()` wires up the real - * GitHub I/O and argv dispatch. + * are pure and exported for tests. `postConsolidatedReview` drives the `post` + * subcommand against an injected `ReviewIo` fake in tests. * * Run in CI as: `bun .github/scripts/ai-review/post-review.ts `. */ export const AI_REVIEW_MARKER = ""; const SUPERSEDED_SUMMARY = "Superseded by a newer AI review"; -/** Hidden marker `isSuperseded` looks for. Kept out of the human-readable - * `SUPERSEDED_SUMMARY` text and broken by `sanitizeModelText` so a model - * can't forge or evade a supersede by echoing the visible text into a - * `claim`/`summary` field. */ +/** Hidden marker `isSuperseded` checks for. Kept out of the human-readable + * `SUPERSEDED_SUMMARY` text, and broken by `sanitizeModelText`, so a model + * can't forge or evade a supersede by echoing it into a `claim`/`summary` field. */ const SUPERSEDED_MARKER = ""; const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; const GITHUB_REVIEW_BODY_MAX = 65536; -// --- Shared types (mirror the two schema files by hand; keep in sync) --- +// Mirrors the two schema files under `.github/ai-review/`; keep them in sync by hand. export type Severity = "critical" | "major" | "minor" | "nit"; export type Verdict = "confirmed" | "refuted" | "uncertain"; @@ -107,12 +89,8 @@ export interface MergedReview { stats: MergedReviewStats; } -// --- Hand-rolled schema validators --- -// -// `.github/ai-review/findings.schema.json` and `merged-review.schema.json` -// are the model-facing contract (passed as `--json-schema`/`output-schema-file`); -// these validators are the runtime enforcement and must be kept in sync with -// them by hand whenever either shape changes. +// Runtime enforcement for the schemas in `.github/ai-review/`; keep these in +// sync by hand whenever either shape changes. function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -219,12 +197,11 @@ function expectCategory(value: unknown, path: string, context: string): string { return str; } -/** `file` is model-controlled and rendered inside `` `code` `` spans at - * several sites; a backtick, newline, other ASCII control char, or `<` in it - * could break out of the span (markdown/HTML injection, mention/#ref pings) - * or forge one of the hidden HTML-comment markers. Reject those at parse - * time as the primary defense; `sanitizeFilePath` neutralizes the same - * characters again at render time in case a caller ever skips validation. */ +/** `file` is model-controlled and rendered inside `` `code` `` spans; a + * backtick, newline, other ASCII control char, or `<` in it could break out + * of the span or forge a hidden marker, so reject those at parse time. + * `sanitizeFilePath` neutralizes the same characters again at render time in + * case a caller skips validation. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_FORBIDDEN_PATTERN = /[`<\x00-\x1f\x7f]/; @@ -385,8 +362,6 @@ export function assertMergedReview(value: unknown): asserts value is MergedRevie parseMergedReview(value); } -// --- Diff anchoring --- - const DIFF_GIT_HEADER = /^diff --git /; const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/; const NEW_FILE_HEADER = /^\+\+\+ (?:b\/(.+)|\/dev\/null)$/; @@ -408,15 +383,13 @@ function stripTrailingTab(path: string): string { } /** - * Parses a unified diff into, for each file, the set of new-side (RIGHT) line - * numbers present in the diff — i.e. the lines a PR review comment can - * anchor to. Context and `+` lines advance the RIGHT counter and are - * anchorable; `-` lines don't exist on the new side and are skipped. + * Parses a unified diff into, for each file, the new-side (RIGHT) line + * numbers a PR review comment can anchor to. Context and `+` lines advance + * the RIGHT counter; `-` lines don't exist on the new side and are skipped. * - * Tracks whether we're inside a hunk so a `+++ ` file header is only ever - * recognized between a `diff --git` boundary and that file's first `@@` - * hunk — otherwise an added/context line whose literal content happens to - * start with `+++ ` (a `+++`-lookalike) could hijack `currentFile`. + * A `+++ ` file header is only recognized between a `diff --git` boundary and + * that file's first `@@` hunk, so an added/context line that happens to start + * with `+++ ` can't hijack `currentFile`. */ export function parseDiffAnchors(diff: string): Map> { const anchors = new Map>(); @@ -461,8 +434,6 @@ function isAnchorable(anchors: Map>, file: string, line: num return anchors.get(file)?.has(line) ?? false; } -// --- Findings partitioning and rendering --- - export interface PartitionedFindings { /** Confirmed/uncertain findings whose start line lands on a diff hunk; posted as inline comments. */ anchorable: MergedFinding[]; @@ -510,17 +481,14 @@ const HTML_COMMENT_OPENER_PATTERN = /"); } -/** Neutralizes the same characters `expectFile` rejects at parse time - * (backtick, `<`, ASCII control chars) inside a model-provided `file` path - * before it's rendered into a `` `code` `` span. Every finding reaching a - * render site will already have passed `expectFile`; this is defense-in-depth - * for any caller that renders a `MergedFinding` without going through - * `assertMergedReview` first. */ +/** Strips the same characters `expectFile` rejects at parse time (backtick, + * `<`, ASCII control chars) so a caller that renders a `MergedFinding` + * without going through `assertMergedReview` first still can't break a + * rendered `` `code` `` span. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_UNSAFE_CHARS = /[`<\x00-\x1f\x7f]/g; @@ -629,9 +588,8 @@ export function renderInlineComment(finding: MergedFinding): string { export interface ReviewFooterInfo { trigger: Trigger; runUrl: string; - /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Passed in from the workflow's - * `CLAUDE_MODEL`/`CODEX_MODEL` env vars instead of being hardcoded here, so - * the model names have one source of truth. */ + /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from the workflow's + * model env vars so there's one source of truth for the model names. */ modelsFooter: string; } @@ -768,10 +726,8 @@ export function buildReviewPayload( const partitioned = partitionFindings(review.findings, anchors); const comments = partitioned.anchorable.map((finding) => buildInlineComment(finding, anchors)); const body = renderReviewBody(review, partitioned, footer); - // A body-only review (many non-anchorable findings, few or no inline - // comments) has no fold-retry path to truncate it on a 422 — truncate the - // very first payload too, so an oversized body posts truncated instead of - // throwing when GitHub rejects it for exceeding the review body cap. + // A body-only review has no 422 fold-retry to truncate it later, so + // truncate the first payload too rather than posting an oversized body. return { event: "COMMENT", body: truncateReviewBody(body, footer.runUrl), comments }; } @@ -791,10 +747,7 @@ export function foldInlineCommentsIntoBody(payload: ReviewPayload): ReviewPayloa } /** Truncates a review body to GitHub's 65536-char review body cap, appending - * an explicit truncation marker + the workflow run URL. Applied to both the - * very first payload (`buildReviewPayload`) and the folded 422-retry body - * (every inline comment stuffed into one body), a no-op when the body is - * already under the cap. */ + * a truncation marker with the workflow run URL. No-op under the cap. */ export function truncateReviewBody(body: string, runUrl: string): string { if (body.length <= GITHUB_REVIEW_BODY_MAX) { return body; @@ -822,8 +775,6 @@ export function supersededBody(oldBody: string): string { ].join("\n"); } -// --- Injected GitHub I/O --- - export interface MarkedEntry { id: number; body: string; @@ -863,12 +814,10 @@ function isSupersedableAiEntry(entry: MarkedEntry): boolean { ); } -/** Snapshots the prior AI reviews/comments to supersede. MUST run before the - * new review is posted: the fresh review is itself a marker-bearing bot - * review, so a post-hoc listing would sweep it into its own supersede pass - * and every new review would immediately collapse as "superseded". - * Best-effort — a listing failure degrades to an empty snapshot (prior runs - * stay unwrapped) rather than costing the real review. */ +/** Snapshots the prior AI reviews/comments to supersede. Must run before the + * new review is posted, or a post-hoc listing would sweep the fresh review + * into its own supersede pass. Best-effort — a listing failure degrades to + * an empty snapshot rather than costing the real review. */ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise { try { const [reviews, comments] = await Promise.all([ @@ -886,9 +835,8 @@ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise< } /** Wraps the snapshotted prior AI reviews/comments in a superseded `
` - * block. Best-effort: a cosmetic failure here (e.g. a transient 404 on a - * review that was deleted mid-run) must never fail the pipeline after the - * real review has already been posted. */ + * block. Best-effort: a cosmetic failure here must never fail the pipeline + * after the real review has already been posted. */ async function supersedePriorRunsBestEffort( io: ReviewIo, prNumber: number, @@ -945,8 +893,6 @@ export async function postConsolidatedReview( await supersedePriorRunsBestEffort(io, prNumber, prior); } -// --- Real GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -995,11 +941,7 @@ function isRecordEntry(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson( response: Response, assert: (value: unknown) => asserts value is T, @@ -1132,9 +1074,7 @@ async function postReview( "application/vnd.github+json", [422], ); - // `githubFetch` only returns without throwing for a 2xx or the allowed - // 422; read the body for the 422 case too so a second failed retry can - // surface it instead of discarding it. + // Read the body for the 422 case too so a second failed retry can surface it. if (response.status === 422) { return { status: response.status, body: await response.text() }; } @@ -1172,9 +1112,6 @@ async function runPost(): Promise { const trigger = parseTrigger(requireEnv("TRIGGER")); const runUrl = requireEnv("RUN_URL"); const mergedReviewPath = requireEnv("MERGED_REVIEW_PATH"); - // Sourced from the workflow's top-level `env:` block (the same values fed - // to the `claude`/`codex-action` invocations), not hardcoded here, so the - // model names have one source of truth. const claudeModel = requireEnv("CLAUDE_MODEL"); const codexModel = requireEnv("CODEX_MODEL"); @@ -1189,8 +1126,7 @@ async function runPost(): Promise { console.log(`Posted AI review on PR #${prNumber} (${raw.findings.length} finding(s)).`); } -/** Reads a JSON file, redacts every string value in place through - * `redactSecretsDeep`, and writes it back — the `redact` subcommand's I/O. */ +/** Reads a JSON file, redacts every string value in place, and writes it back. */ async function runRedact(path: string): Promise { const raw: unknown = JSON.parse(await Bun.file(path).text()); const redacted = redactSecretsDeep(raw); diff --git a/.github/scripts/ai-review/resolve.test.ts b/.github/scripts/ai-review/resolve.test.ts index 0beb52747d..07453c1c32 100644 --- a/.github/scripts/ai-review/resolve.test.ts +++ b/.github/scripts/ai-review/resolve.test.ts @@ -361,8 +361,6 @@ describe("resolveDecision: issue_comment authorization", () => { io, ); expect(result.shouldRun).toBe(true); - // The effective permission is always resolved (only the write-permission - // requirement short-circuits for OWNER), so the lookup still happens. expect(permissionLookups).toEqual(["maintainer"]); expect(reactions).toEqual([555]); }); diff --git a/.github/scripts/ai-review/resolve.ts b/.github/scripts/ai-review/resolve.ts index 400d0e3973..58df368df1 100644 --- a/.github/scripts/ai-review/resolve.ts +++ b/.github/scripts/ai-review/resolve.ts @@ -1,25 +1,19 @@ /** * AI review resolver: decides whether the one-shot AI review pipeline should - * run for a PR. + * run for a PR. The pipeline runs once per PR, so this is the only gate + * between a new commit landing and Claude/Codex burning API budget again. * - * The pipeline runs EXACTLY ONCE per PR, so this is the only gate standing - * between "new commit lands" and "Claude + Codex burn API budget again". Two - * triggers feed it: - * - manual (`workflow_dispatch` or an internal maintainer's `/ai-review` - * issue comment): a human explicitly asked for a review, so the - * marker/dedup guard and the draft/fork/bot skips are bypassed. - * - auto (`pull_request` `opened`/`ready_for_review`): only PRs whose - * author has repository write access get the automatic review. Skips - * drafts, bots, fork PRs, authors without write access (external - * contributors go through the manual maintainer path), and PRs that - * already carry a marker comment/review from a prior run. + * Two triggers: + * - manual (`workflow_dispatch` or a maintainer's `/ai-review` issue + * comment): bypasses the marker/dedup guard and the draft/fork/bot skips. + * - auto (`pull_request` `opened`/`ready_for_review`): only for PRs whose + * author has repository write access; skips drafts, bots, fork PRs, and + * PRs that already carry a marker comment/review from a prior run. * - * `resolveDecision` is the pure orchestration function (I/O injected, like - * `evaluateAllOpenPrs` in `contribution-gate.ts`) that a test can drive - * without the network; `main()` wires up the real GitHub I/O, writes the - * step outputs `should_run`, `pr_number`, `head_ref`, and `trigger` to - * `$GITHUB_OUTPUT`, and surfaces the skip reason (if any) in - * `$GITHUB_STEP_SUMMARY`. + * `resolveDecision` is the pure orchestration function (I/O injected) a test + * can drive without the network; `main()` wires up the real GitHub I/O, + * writes `should_run`/`pr_number`/`head_ref`/`trigger` to `$GITHUB_OUTPUT`, + * and surfaces the skip reason (if any) in `$GITHUB_STEP_SUMMARY`. * * Run in CI as: `bun .github/scripts/ai-review/resolve.ts`. */ @@ -29,14 +23,11 @@ import { appendFileSync } from "node:fs"; import { fetchAuthorPermission, WRITE_PERMISSIONS } from "../contribution-gate.ts"; import { AI_REVIEW_MARKER } from "./post-review.ts"; -// Re-export so existing consumers (tests, this file's own dedup check) can -// keep importing the marker from `resolve.ts`; `post-review.ts` — which owns -// posting — is the single source of truth for the literal. +// `post-review.ts` owns the literal; re-exported here for convenience. export { AI_REVIEW_MARKER }; /** Login every review/comment posted by this workflow carries. Duplicated - * (not imported) from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two - * literals in sync. */ + * from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two in sync. */ const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; export type EventName = "workflow_dispatch" | "issue_comment" | "pull_request"; @@ -96,11 +87,9 @@ export interface ResolveResult { trigger: Trigger; } -/** No size gate: Claude and Codex review agentically — reading the diff and the - * changed files via their own tools over many turns, like the local CLI — so a - * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of its - * size. Very large diffs are handled best-effort within the model's - * context/turn budget. */ +/** No size gate: Claude and Codex review agentically over many turns, so any + * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of + * size, best-effort within the model's context/turn budget. */ function decideForPr(trigger: Trigger): ResolveResult { return { shouldRun: true, trigger }; } @@ -128,9 +117,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi throw new Error("issue_comment trigger requires comment details"); } - // Authoritative command match: the workflow's job `if:` only - // pre-filters on `startsWith('/ai-review')`, so `/ai-reviewers` or - // `/ai-review-please` would otherwise also reach here. + // The workflow's job `if:` only pre-filters on `startsWith('/ai-review')`, + // so `/ai-reviewers` or `/ai-review-please` would otherwise also reach here. const firstLine = comment.body.split("\n")[0]?.trim() ?? ""; if (firstLine !== "/ai-review") { return { @@ -140,13 +128,9 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative authorization: always resolve the commenter's - // effective repository permission and require write/admin. Only the - // repository OWNER may short-circuit that requirement — any other - // association (including MEMBER/COLLABORATOR, which merely mean "in - // the org"/"added as a collaborator", not necessarily push-capable) - // must pass the permission check. Mirrors `contribution-gate.ts`'s - // `WRITE_PERMISSIONS`. + // Only the repository OWNER short-circuits the permission check; any + // other association (MEMBER/COLLABORATOR only mean "in the org"/"added + // as a collaborator", not necessarily push-capable) must pass it. const permission = await io.fetchPermission(comment.authorLogin); const authorized = comment.authorAssociation === "OWNER" || @@ -175,8 +159,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } - // Auto trigger (`pull_request` events): internal PRs only, fires at most - // once per PR. if (pr.draft) { return { shouldRun: false, skipReason: "PR is a draft.", trigger }; } @@ -191,13 +173,10 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative auto-trigger authorization: only PRs authored by someone - // with effective repository write access are reviewed automatically. This - // is the actual author check, not defense-in-depth — a same-repo head - // branch only proves the branch exists in this repo, not that the AUTHOR - // pushed it (a PR can be opened from a branch someone else pushed). An - // unresolvable permission counts as unauthorized. Mirrors the manual - // path's gate above and `contribution-gate.ts`'s `WRITE_PERMISSIONS`. + // A same-repo head branch only proves the branch exists in this repo, not + // that the author pushed it, so this checks the author's own permission + // rather than trusting the branch's origin. An unresolvable permission + // counts as unauthorized. const authorPermission = await io.fetchPermission(pr.authorLogin); if (authorPermission === undefined || !WRITE_PERMISSIONS.has(authorPermission)) { return { @@ -214,9 +193,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi io.listReviews(pr.number), io.listIssueComments(pr.number), ]); - // Only a marker posted BY the workflow bot counts — otherwise anyone could - // paste the (invisible) marker into a comment to permanently suppress the - // auto review of their own PR. + // Only a marker posted by the workflow bot counts, or anyone could paste + // the invisible marker into a comment to suppress their own PR's review. const alreadyReviewed = [...reviews, ...comments].some( (entry) => entry.authorLogin === WORKFLOW_BOT_LOGIN && entry.body.includes(AI_REVIEW_MARKER), ); @@ -231,8 +209,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } -// --- GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -275,11 +251,7 @@ function isRecordEntry(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson( response: Response, assert: (value: unknown) => asserts value is T, @@ -389,10 +361,8 @@ async function reactToComment(token: string, base: string, commentId: number): P }); } -/** Writes each `$GITHUB_OUTPUT` value using the heredoc/delimiter form (with - * a random delimiter per line) rather than `name=value`, defensively — none - * of today's values can contain a newline, but a future value shouldn't be - * able to inject extra output lines either. */ +/** Writes each `$GITHUB_OUTPUT` value with a random per-line delimiter rather + * than `name=value`, so a future multi-line value can't inject extra output lines. */ function writeOutputs(result: ResolveResult, prNumber: number): void { const outputFile = requireEnv("GITHUB_OUTPUT"); const entries: Record = { @@ -410,9 +380,7 @@ function writeOutputs(result: ResolveResult, prNumber: number): void { appendFileSync(outputFile, `${lines.join("\n")}\n`); } -/** Surfaces the skip reason (if any) in the job's step summary — the only - * place it's actually read; it's not exposed as a job `outputs:` because - * nothing downstream consumes it there. */ +/** Surfaces the skip reason (if any) in the job's step summary. */ function writeStepSummary(result: ResolveResult): void { if (!result.skipReason) { return; diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts index 7951de817a..6c8ed6a544 100644 --- a/.github/scripts/contribution-gate.test.ts +++ b/.github/scripts/contribution-gate.test.ts @@ -21,9 +21,6 @@ describe("isInternalAuthor", () => { ); 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); }); @@ -130,8 +127,6 @@ describe("evaluateGate", () => { }); 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, @@ -231,7 +226,6 @@ describe("evaluateAllOpenPrs", () => { 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"); }); @@ -275,9 +269,6 @@ describe("fetchAuthorPermission", () => { }); 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(); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts index 061fc7107b..5d2666446d 100644 --- a/.github/scripts/contribution-gate.ts +++ b/.github/scripts/contribution-gate.ts @@ -1,13 +1,11 @@ /** - * Contribution gate: enforces the Supabase CLI contribution workflow across all - * OPEN pull requests opened by external contributors. + * Contribution gate: enforces the Supabase CLI contribution workflow across + * every open pull request 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. + * A PR passes only when it links to an open GitHub issue that carries the + * `open-for-contribution` label. Maintainers (recognized by their effective + * repository permission, so private org members count too) and bots are + * exempt. PRs that don't 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` @@ -16,13 +14,10 @@ * 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. + * The workflow checks out the base branch in both modes, so this only ever + * executes trusted repository 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"; @@ -30,25 +25,19 @@ 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. + * `author_association` only reports `MEMBER` for a *public* org membership; a + * private member is reported as `CONTRIBUTOR`/`NONE`, so this set alone can't + * identify every internal maintainer — see `isInternalAuthor`. */ 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. + * Effective repository permissions that mark an author as internal: anyone + * who can push, directly or via a team/org grant `author_association` won't + * surface. The legacy REST `permission` field collapses `maintain` into + * `write`, so `admin`/`write` covers every push-capable role. * - * Exported for `resolve.ts`, which requires the same write-permission bar to - * authorize a `/ai-review` command. + * Exported for `resolve.ts`, which uses the same bar to authorize `/ai-review`. */ export const WRITE_PERMISSIONS = new Set(["admin", "write"]); @@ -147,9 +136,8 @@ export function evaluateGate(input: GateInput): GateResult { 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. + // Only issues in this repository count; a cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls. const repo = input.repository.toLowerCase(); const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); @@ -234,8 +222,6 @@ export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promis return entries; } -// --- GitHub I/O (only runs when executed directly) --- - interface GraphQLIssueNode { number: number; state: "OPEN" | "CLOSED"; @@ -358,16 +344,11 @@ async function fetchOpenPullRequests( } /** - * 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. + * Resolves an author's effective permission on the repository, recognizing + * private org members that `author_association` reports only as + * `CONTRIBUTOR`. A 404 means the author isn't a collaborator at all — the + * common case for external fork contributors — so it maps to `undefined` + * rather than throwing; other failures still throw. */ export async function fetchAuthorPermission( token: string, @@ -426,9 +407,8 @@ async function runSinglePr( 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. + // `author_association` only exposes public org membership, so a private + // member must be confirmed via the effective repository permission. let permission: string | undefined; let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); if (!isInternal) { diff --git a/.github/scripts/sync-workload-catalog.test.ts b/.github/scripts/sync-workload-catalog.test.ts index 9f4f129795..4e362c4f35 100644 --- a/.github/scripts/sync-workload-catalog.test.ts +++ b/.github/scripts/sync-workload-catalog.test.ts @@ -148,7 +148,6 @@ describe("planCatalogUpdate", () => { expect(plan.target).toBe("default"); expect(plan.source).toContain(`"postgres",\n "17.6.1.169",`); expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.169@${DIGEST_A}"`); - // The additional line must survive untouched. expect(plan.source).toContain(`"15.14.1.168":`); expect(plan.source).toContain(`sha256:${"f".repeat(64)}`); }); @@ -167,7 +166,6 @@ describe("planCatalogUpdate", () => { expect(plan.target).toBe("additional"); expect(plan.source).toContain(`"15.14.1.169":`); expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:15.14.1.169@${DIGEST_B}"`); - // The 17.x default is the regression this guards: it must not move. expect(plan.source).toContain(`"postgres",\n "17.6.1.168",`); expect(plan.source).toContain(`sha256:${"9".repeat(64)}`); }); @@ -240,7 +238,6 @@ describe("planCatalogUpdate", () => { expect(plan.kind).toBe("updated"); if (plan.kind !== "updated") return; expect(plan.previousVersion).toBe("v16.2"); - // postgres keeps both of its own pins. expect(plan.source).toContain(`"17.6.1.168",`); expect(plan.source).toContain(`"15.14.1.168":`); }); @@ -276,8 +273,7 @@ describe("against the real catalog", () => { version: "99.99.99", digest: DIGEST_A, }); - // 99.x is a line no service carries, so postgres (multi-line) skips while - // every single-line service bumps. Either way it must be recognised. + // 99.x is a line no service carries: multi-line postgres skips, single-line services bump. expect( bumped.kind === "updated" || bumped.kind === "unmodelled-release-line", `${service} was not addressable in the real catalog`, diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index b7dc5fa18c..a386875f9d 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -1,17 +1,12 @@ name: AI Review -# One-shot AI code review: replaces the Codex GitHub App's automatic -# per-push reviews (which churned 30-40 short rounds per PR) with a single -# exhaustive pass that runs at most once per PR. See -# .github/ai-review/README.md for the full design and security model. +# One-shot AI code review: replaces the Codex GitHub App's automatic per-push reviews with a +# single exhaustive pass that runs at most once per PR. See .github/ai-review/README.md for the +# full design and security model. # -# Three ways to trigger a run: -# - workflow_dispatch, for testing / ad-hoc runs against any PR number. -# - an internal maintainer commenting `/ai-review` on a PR. -# - automatically, when a PR opens or leaves draft. resolve.ts gates the -# automatic path to PR authors with repository write access; external -# contributors' PRs are skipped and go through the manual `/ai-review` -# maintainer path instead. +# Triggered by workflow_dispatch (testing/ad-hoc), an internal maintainer commenting +# `/ai-review`, or automatically when a PR opens or leaves draft — resolve.ts gates the automatic +# path to PR authors with write access; external contributors go through the manual path instead. on: workflow_dispatch: inputs: @@ -29,30 +24,21 @@ on: permissions: {} -# One source of truth for the two model names — `resolve`/`claude-review`/ -# `codex-review` all read these instead of hardcoding them a second and -# third time, and `post-review`'s footer reads them too (see the "Post -# review" step below). +# One source of truth for the model names; resolve, claude-review, codex-review, and +# post-review's footer all read these instead of hardcoding them separately. env: CLAUDE_MODEL: claude-opus-5 CODEX_MODEL: gpt-5.6-sol -# Ordinary (non-command) issue_comment events fire this workflow for EVERY -# comment on EVERY PR; with only the PR number in the group, any comment -# (even one that isn't `/ai-review`) would cancel an in-flight review via -# `cancel-in-progress`. Give those runs their own per-run group so they can -# never cancel a real review. The command test is exact equality -# (`!= '/ai-review'`), mirroring resolve.ts's first-line check — `startsWith` -# would let a near-miss like `/ai-reviewers` (which resolve.ts rejects) land -# in a shared group and cancel a running review anyway. +# Non-command issue_comment events fire for every comment on every PR, so grouping by PR number +# alone would let any comment cancel an in-flight review; give those runs their own per-run +# group instead. The command check is exact equality (mirroring resolve.ts), so a near-miss like +# `/ai-reviewers` can't land in the shared group and cancel a real review. # -# `pull_request` events get their own per-PR `auto` group, separate from the -# manual (`/ai-review` / dispatch) `review` group: an auto event may well -# resolve to a SKIP (dedup, no write access), and letting it share the manual -# group would let e.g. a ready_for_review event cancel an in-flight -# maintainer-requested review and then not replace it. The cost is that an -# auto and a manual run can overlap on the same PR — rare, and self-healing, -# since the later post supersedes the earlier review. +# `pull_request` events get a separate per-PR `auto` group from the manual `review` group, since +# an auto event may resolve to a skip and would otherwise cancel an in-flight maintainer-requested +# review without replacing it. An auto and manual run can overlap on the same PR as a result — +# rare, and self-healing since the later post supersedes the earlier one. concurrency: group: >- ai-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr }}-${{ @@ -66,21 +52,17 @@ jobs: name: Resolve runs-on: ubuntu-latest timeout-minutes: 5 - # For issue_comment events, only PR comments starting with /ai-review - # AND carrying an association that could plausibly be a maintainer reach - # this job at all. This is a cheap, non-authoritative pre-filter - # (defense-in-depth only): it can't see a private org member's real - # permission, so it can under-admit. The authoritative checks — the - # EXACT command match and the effective-permission lookup — happen in - # resolve.ts, which is the actual gate. + # Only PR comments starting with /ai-review, from an association that could plausibly be a + # maintainer, reach this job — a cheap, non-authoritative pre-filter (it can't see a private + # org member's real permission, so it can under-admit). The authoritative checks — exact + # command match and effective-permission lookup — happen in resolve.ts. if: > github.event_name != 'issue_comment' || (github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/ai-review') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) - # `resolve` reacts 👀 to the triggering comment (pull-requests: write) but - # runs ONLY trusted, default-branch code (see the pinned checkout ref - # below) — never a PR's own code — so granting it write is safe. + # Reacts 👀 to the triggering comment (pull-requests: write) but runs only trusted, + # default-branch code (see the pinned checkout ref below), never a PR's own — safe to grant write. permissions: pull-requests: write contents: read @@ -90,9 +72,8 @@ jobs: head_ref: ${{ steps.resolve.outputs.head_ref }} trigger: ${{ steps.resolve.outputs.trigger }} steps: - # Base repo, default ref, pinned explicitly — this job runs trusted - # repository code exclusively, and must keep doing so even though the - # `pull_request` trigger above hands it PR-authored event payloads. + # Pinned to the default ref explicitly — this job must keep running only trusted repo code, + # even though the pull_request trigger above hands it PR-authored event payloads. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} @@ -119,14 +100,11 @@ jobs: if: needs.resolve.outputs.should_run == 'true' runs-on: ubuntu-latest timeout-minutes: 60 - # SECURITY-CRITICAL: this job checks out the PR's own head commit, which - # is untrusted review subject matter, not something this job trusts with - # more access. Nothing this job EXECUTES may come from that checkout: - # prompts, the findings schema, and the validation script are all read - # from a SEPARATE trusted checkout of the default branch (`path: trusted` - # below). The job holds no write permissions, a read-only Claude tool - # allowlist (no write/edit tools, no Bash), and no secrets beyond - # ANTHROPIC_API_KEY. + # Security-critical: this job checks out the PR's own head commit as untrusted review subject + # matter only — nothing it executes may come from that checkout. Prompts, the findings schema, + # and the validation script are all read from a separate trusted default-branch checkout + # (`path: trusted` below). The job holds no write permissions, a read-only Claude tool + # allowlist (no write/edit tools, no Bash), and no secrets beyond ANTHROPIC_API_KEY. permissions: contents: read pull-requests: read @@ -169,11 +147,10 @@ jobs: base_ref=$(jq -r '.baseRefName' /tmp/ai-review/pr.json) bun .github/scripts/ai-review/generate-pr-diff.ts "$PR" "$base_ref" - # Pin the exact published version so a new Claude Code release can't - # silently change review behavior mid-rollout; bump deliberately. - # Install from the TRUSTED checkout with npm config isolation so a - # PR-supplied `.npmrc`/`.npmrc`-adjacent config in the untrusted `pr` - # checkout can never redirect this install to a hostile registry. + # Pin the exact published version so a Claude Code release can't silently change review + # behavior mid-rollout. Installed from the trusted checkout with npm config isolation so a + # PR-supplied `.npmrc` in the untrusted `pr` checkout can never redirect this install to a + # hostile registry. - name: Install Claude Code CLI working-directory: trusted run: | @@ -187,18 +164,13 @@ jobs: --globalconfig "${RUNNER_TEMP}/ai-review-npmrc-global" \ --registry=https://registry.npmjs.org/ @anthropic-ai/claude-code@2.1.247 - # SECURITY-CRITICAL invariant: PR code is only ever READ by `claude`, - # via the `( cd .../pr && claude ... )` subshell below — nothing else in - # this step, and no `bun` process anywhere in this job, ever runs with - # a cwd inside `pr`. `bun` auto-loads `bunfig.toml` (`preload` runs - # arbitrary code) and `.env` from its cwd; a `pr`-cwd `bun` invocation - # would let a PR-authored `pr/bunfig.toml` execute attacker code in a - # step that holds `ANTHROPIC_API_KEY`. `claude` is a standalone binary - # (not run via `bun`), so `bunfig.toml` never applies to it; `--bare` - # already disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` is - # belt-and-suspenders against a future CLI regression. The step's own - # `working-directory: trusted` keeps `jq` and `bun` on the trusted - # checkout for everything outside that one subshell. + # Security-critical invariant: PR code is only ever read by `claude`, in the `pr`-cwd + # subshell below — no other command in this job, including any `bun` call, runs with a cwd + # inside `pr`. `bun` auto-loads `bunfig.toml` (arbitrary `preload` code) and `.env` from its + # cwd, so a `pr`-cwd `bun` call would let a PR-authored `bunfig.toml` execute in a step + # holding `ANTHROPIC_API_KEY`. `claude` is a standalone binary unaffected by `bunfig.toml`; + # `--bare` disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` guards against a future + # CLI regression. - name: Run Claude review working-directory: trusted env: @@ -249,15 +221,10 @@ jobs: exit 1 fi - # Scrubs any secret-shaped substring a prompt-injected model might have - # echoed back (e.g. from `Read`-ing a secret-bearing path) out of the - # raw JSON before it's uploaded as a (public-repo) artifact; the posted - # review is scrubbed separately at render time. `if: always()` so a - # partial `claude-raw.json` from a failed attempt is still scrubbed - # before the always-on upload step below; guarded because - # `claude-findings.json` may not exist if every attempt failed before - # the extraction step. Runs from the trusted cwd, same as every other - # `bun` invocation in this job. + # Scrubs any secret-shaped substring a prompt-injected model might have echoed back before + # the raw JSON is uploaded as a public-repo artifact; the posted review is scrubbed + # separately at render time. Runs with `if: always()` so a partial `claude-raw.json` from a + # failed attempt is still scrubbed before the always-on upload step. - name: Redact secrets from Claude findings if: always() working-directory: trusted @@ -284,11 +251,10 @@ jobs: name: Codex review needs: resolve if: needs.resolve.outputs.should_run == 'true' - # Codex's INDEPENDENT review. It no longer depends on claude-review, so it - # runs IN PARALLEL with it. It works purely from /tmp/ai-review/pr.diff - # (absolute path in its prompt), so it needs no PR-head checkout — its ONLY - # checkout is the trusted default branch. The verify-by-reading step (which - # does need the PR's files) is the separate `adjudicate` job below. + # Codex's independent review doesn't depend on claude-review, so it runs in parallel. It + # works purely from /tmp/ai-review/pr.diff, so it needs only the trusted default-branch + # checkout — no PR-head checkout. The verify-by-reading step (which needs the PR's files) is + # the separate `adjudicate` job below. permissions: contents: read pull-requests: read @@ -321,29 +287,18 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/findings.schema.json > /tmp/ai-review/findings.schema.json - # Safety strategy (drop-sudo + read-only), verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts — see - # the adjudicate job below for the full rationale. In short: Codex runs as - # a non-sudo-capable user, in a sandbox with no filesystem writes and no - # network, with no `codex-args`/`--sandbox` duplication. + # Safety strategy (drop-sudo + read-only) — see the "Run Codex adjudication" step below for + # the full rationale (verified against the pinned openai/codex-action's action.yml + + # src/runCodexExec.ts). In short: non-sudo user, no filesystem writes, no network. - name: Run Codex independent review - # Pinned to v1.11 (52fe01ec…), NOT v1.12 (86365089…): v1.12 has two - # confirmed, still-open upstream regressions that hang or kill the job - # on exactly this workflow's config (safety-strategy: drop-sudo, - # sandbox: read-only, output-schema-file) — openai/codex-action#151 - # (the wrapper waits on the child process's `close` event with - # inherited stdio, so a lingering descendant keeps the step alive - # forever after Codex has already written its output) and - # openai/codex-action#160 (drop-sudo's v1.12 rewrite chmods - # root-owned /run service sockets, breaking systemd-resolved on the - # GitHub-hosted runner itself, which kills the job ~52-65 minutes in - # regardless of job timeout-minutes). Both are independent of the - # pinned Codex CLI version. This pin was silently regressed to v1.12 - # once already by a grouped Dependabot bump (#6484) — see the - # dependabot.yml ignore entry for openai/codex-action, which exists - # specifically to stop that from happening again. There is no - # released fix above v1.12 as of 2026-09-09; re-verify both issues - # are closed before ever re-bumping this pin. + # Pinned to v1.11 (52fe01ec…), not v1.12 (86365089…): v1.12 has two open upstream + # regressions on this workflow's config (safety-strategy: drop-sudo, sandbox: read-only, + # output-schema-file) — openai/codex-action#151 (a lingering descendant process keeps the + # step alive after Codex already wrote its output) and openai/codex-action#160 + # (drop-sudo's rewrite chmods root-owned /run sockets, breaking systemd-resolved and + # killing the job regardless of timeout-minutes). A dependabot.yml ignore entry for + # openai/codex-action stops an automated bump from silently regressing this pin; + # re-verify both issues are closed before ever re-bumping it. uses: &codex-action-pin openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1.11 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -352,8 +307,6 @@ jobs: effort: high output-schema-file: /tmp/ai-review/findings.schema.json output-file: /tmp/ai-review/codex-findings.json - # Pinned explicitly (verified via `npm view @openai/codex version`); - # never left floating. codex-version: "0.150.1" working-directory: ${{ github.workspace }} safety-strategy: drop-sudo @@ -389,21 +342,17 @@ jobs: - resolve - claude-review - codex-review - # Runs when AT LEAST ONE independent review succeeded — a single flaky model - # job must not sink the whole review. Each findings download below is guarded - # by its job's result, and the stage step substitutes an empty findings set - # for any review that didn't complete, so the adjudicator reconciles 1 or 2. + # Runs when at least one independent review succeeded — a single flaky model job must not + # sink the whole review. Findings download steps are guarded per job result, and staging + # substitutes an empty findings set for any review that didn't complete. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && (needs.claude-review.result == 'success' || needs.codex-review.result == 'success') }} - # SECURITY-CRITICAL: this job checks out the PR head (untrusted subject - # matter) so Codex can VERIFY findings by reading the real files. Codex runs - # with its working directory at the workspace ROOT, which holds only the - # `pr/` and `trusted/` checkouts (no AGENTS.md/config of its own), and reads - # `pr/` read-only; the adjudicate prompt's injection guard treats every file - # under `pr/` (including any AGENTS.md/CLAUDE.md) as untrusted data. Every - # `bun` invocation runs from `trusted/`. Blast radius of a prompt-injected - # Codex here is bounded to review CONTENT: read-only sandbox, no network, - # key proxied by the action, and the output is secret-scrubbed before it - # leaves this job. + # Security-critical: this job checks out the PR head (untrusted subject matter) so Codex can + # verify findings by reading the real files. Codex's working directory is the workspace root, + # which holds only `pr/` and `trusted/` (no AGENTS.md/config of its own); it reads `pr/` + # read-only, and the adjudicate prompt's injection guard treats every file under `pr/` + # (including any AGENTS.md/CLAUDE.md) as untrusted data. Every `bun` invocation runs from + # `trusted/`. A prompt-injected Codex here is bounded to review content: read-only sandbox, + # no network, key proxied by the action, and secret-scrubbed output. permissions: contents: read pull-requests: read @@ -485,31 +434,22 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/merged-review.schema.json > /tmp/ai-review/merged-review.schema.json - # Safety strategy, verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts: - # - `safety-strategy: read-only` forces codex-exec's legacy sandbox to - # read-only, but Codex still runs as the action's default, - # sudo-capable user — the action's own docs/security.md calls this - # combination out as unsafe, since a sudo-capable process can read - # secrets like OPENAI_API_KEY out of memory (e.g. via procfs) even - # under a read-only filesystem sandbox with no network. - # - `safety-strategy: drop-sudo` (the action's default) removes sudo - # from the user running Codex, closing that hole, but says nothing - # on its own about Codex's filesystem/network sandbox. - # - `determinePermissionSelection()` only forces the legacy read-only - # sandbox when `safety-strategy === "read-only"`; otherwise it honors - # a separately-set `sandbox` input as-is. So setting BOTH - # `safety-strategy: drop-sudo` and `sandbox: read-only` composes them - # safely: non-sudo user, no filesystem writes, no network — with no - # `codex-args`/`--sandbox` duplication. - # `working-directory` is the workspace root so Codex's cwd holds no - # untrusted AGENTS.md/config; it reads the PR from `pr/` and executes - # nothing from it. + # Safety strategy, per the pinned openai/codex-action's action.yml + src/runCodexExec.ts: + # - `safety-strategy: read-only` forces the legacy sandbox read-only, but Codex still + # runs as the action's default sudo-capable user — the action's own docs call this + # unsafe, since a sudo-capable process can read secrets like OPENAI_API_KEY out of + # memory even under a read-only, no-network sandbox. + # - `safety-strategy: drop-sudo` (the action's default) removes sudo from the user + # running Codex but says nothing on its own about the filesystem/network sandbox. + # - The action only forces its legacy read-only sandbox when `safety-strategy === + # "read-only"`; otherwise it honors a separately-set `sandbox` input as-is. Setting + # `safety-strategy: drop-sudo` and `sandbox: read-only` together composes them safely: + # non-sudo user, no filesystem writes, no network. + # `working-directory` is the workspace root so Codex's cwd holds no untrusted AGENTS.md/ + # config; it reads the PR from `pr/` and executes nothing from it. - name: Run Codex adjudication - # Same v1.11 pin as codex-review's "Run Codex independent review" - # step above — see that step's comment for the full v1.11-vs-v1.12 - # rationale (openai/codex-action#151, #160). YAML-aliased so the SHA - # only needs to change in one place. + # Same v1.11 pin as codex-review's step above (see its comment for the full rationale); + # YAML-aliased so the SHA only needs to change in one place. uses: *codex-action-pin with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -551,23 +491,22 @@ jobs: needs: - resolve - adjudicate - # Runs only when adjudication succeeded (it produced the merged review this - # job posts). `!cancelled()` is required here because an explicit `if` - # replaces the default "all needed jobs succeeded" check. + # Runs only when adjudication succeeded (it produced the merged review this job posts). + # `!cancelled()` is required since an explicit `if` replaces the default "all needed jobs + # succeeded" check. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && needs.adjudicate.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: pull-requests: write steps: - # SECURITY-CRITICAL: this is the only job with write permission, so it - # must only ever execute trusted base-branch code — never the PR head. - # Checking out `develop` explicitly (never `needs.resolve.outputs.head_ref`) - # keeps a malicious PR from smuggling a script change into the one job - # that can write back to the PR. (For `pull_request` events GitHub runs - # the workflow FILE from the PR's own ref; acceptable because the auto - # path only admits same-repo PRs, whose authors hold write access - # anyway, and fork PRs run with a read-only token and no secrets.) + # Security-critical: this is the only job with write permission, so it must only ever + # execute trusted base-branch code — never the PR head. Checking out `develop` explicitly + # (never `needs.resolve.outputs.head_ref`) keeps a malicious PR from smuggling a script + # change into the one job that can write back to the PR. (For `pull_request` events GitHub + # runs the workflow file from the PR's own ref; acceptable since the auto path only admits + # same-repo PRs, whose authors hold write access anyway, and fork PRs run with a read-only + # token and no secrets.) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop @@ -590,7 +529,6 @@ jobs: MERGED_REVIEW_PATH: /tmp/ai-review/merged-review.json TRIGGER: ${{ needs.resolve.outputs.trigger }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level - # `env:` block above — the same values passed to `claude`/ - # `codex-action` — so the footer never drifts from what actually ran. + # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level `env:` block above, + # so the footer never drifts from what actually ran. run: bun .github/scripts/ai-review/post-review.ts post diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index 054fc20d26..0a0b7e60e0 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -1,14 +1,9 @@ name: Apply release notes -# Approval-based publish. When a member of the supabase/cli team approves a -# release-notes PR (head ref `release-notes/v`), this workflow pushes -# the proposed notes to the GitHub Release body for the corresponding tag, -# comments the release URL on the PR, and closes the PR without merging. The -# release-notes PR targets `develop` (not `main`) so an accidental merge can -# never rewrite `main`'s history; the file is not meant to land on any branch. -# -# Mirrors the fast-forward job in release.yml, which already gates on a -# `pull_request_review` + `approved` event. +# Approval-based publish: when a supabase/cli team member approves a release-notes PR (head ref +# `release-notes/v`), this pushes the notes to the GitHub Release body for that tag, +# comments the release URL on the PR, and closes it without merging. The PR targets `develop` +# (not `main`) so an accidental merge can never rewrite `main`'s history. on: pull_request_review: @@ -32,9 +27,8 @@ jobs: outputs: authorized: ${{ steps.check.outputs.authorized }} steps: - # App token: needs `orgs/.../teams/.../memberships` read (the org-installed - # App has it), repo write to edit the release, and PR write to comment - # and close. Matches release.yml's fast-forward step. + # App token needs org membership read, repo write to edit the release, and PR write to + # comment and close. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -47,9 +41,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APPROVER: ${{ github.event.review.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} - # Fail closed: any response other than an active membership means the - # approval is ignored. We post a comment so the reviewer sees why their - # approval didn't apply, then exit 0 so the workflow isn't flagged red. + # Fail closed: any state other than active membership is ignored. Exit 0 (after + # commenting why) so the workflow isn't flagged red. run: | set -euo pipefail status=$(gh api \ @@ -79,9 +72,8 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - # Checkout the PR head so any reviewer edits made in the GitHub UI before - # approval are captured. apply-release-notes.ts reads from the working - # tree. + # Checkout the PR head so reviewer edits made before approval are captured; + # apply-release-notes.ts reads from the working tree. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: ref: ${{ github.event.pull_request.head.sha }} @@ -98,8 +90,7 @@ jobs: HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} APPROVER: ${{ github.event.review.user.login }} - # The branch is named `release-notes/v`, so the tag is just - # the basename. apply-release-notes.ts validates the file's existence. + # The branch is named `release-notes/v`, so the tag is just the basename. run: | set -euo pipefail tag="${HEAD_REF##release-notes/}" diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index e079be038c..3f19507312 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: @@ -25,11 +14,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. + # Swift requires a macOS runner; every other language in the matrix uses blacksmith. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: @@ -51,39 +36,19 @@ jobs: build-mode: autobuild - language: javascript-typescript build-mode: none - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # Placeholder manual-build step; only runs if a language's build-mode above is set to "manual". - if: matrix.build-mode == 'manual' shell: bash run: | diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index 109b85cf49..ed5aa0f22a 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -1,20 +1,9 @@ name: Dispatch cli-e2e-ci -# Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against -# a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). -# -# This is distinct from `live-e2e.yml`, which runs the collocated live suite -# against managed staging (api.supabase.green). Here the same `apps/cli` suite -# runs against a local Supabox stack stood up inside the private cli-e2e-ci repo; -# we only fire the trigger and pass our head SHA — cli-e2e-ci checks that SHA out -# into its `cli` submodule. -# -# Opt-in by label to keep the expensive full-stack run off every PR: add the -# `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). -# cli-e2e-ci reports a `cli-e2e-ci / live` commit status back onto the head SHA. -# -# Fork PRs cannot dispatch (no access to the App secret); run cli-e2e-ci's own -# workflow_dispatch with `cli_ref` for those. +# Triggers supabase/cli-e2e-ci to run the cli `test:live` suite against a full Supabox stack +# built from this PR's head commit — distinct from live-e2e.yml, which targets managed staging. +# Opt in with the `run-live-e2e-ci` label; fork PRs cannot dispatch (no access to the App +# secret) and use cli-e2e-ci's own workflow_dispatch with `cli_ref` instead. on: pull_request: types: [labeled, synchronize, reopened] @@ -24,9 +13,7 @@ permissions: jobs: dispatch: - # Same-repo PRs only: fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so - # the App-token step would fail and leave a red check. Skip them cleanly — - # fork PRs use cli-e2e-ci's workflow_dispatch with `cli_ref` instead. + # Fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so the App-token step would fail; skip them. if: >- contains(github.event.pull_request.labels.*.name, 'run-live-e2e-ci') && github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml index 454dc49c71..7f48630a15 100644 --- a/.github/workflows/mirror-slim-image.yml +++ b/.github/workflows/mirror-slim-image.yml @@ -1,23 +1,14 @@ name: Mirror Slim Image -# Mirrors slim service images published by supabase/slim-services from -# ghcr.io/supabase/cli/: to -# public.ecr.aws/supabase/cli/:. +# Mirrors slim service images published by supabase/slim-services from ghcr.io/supabase/cli to +# public.ecr.aws/supabase/cli. The sender polls the ECR Public destination and fails its release +# unless it resolves to the exact index digest published, so the copy must be digest-preserving +# via `regctl image copy` (byte-for-byte); do not switch to `docker buildx imagetools create`, +# which can rewrite the index and change its digest. # -# The slim-services release pipeline sends a `mirror-slim-image` -# repository_dispatch to this repo, then anonymously polls the ECR Public -# destination (15-minute timeout) and fails its release unless the destination -# resolves to the exact index digest it published. The copy must therefore be -# digest-preserving: we use `regctl image copy`, which moves the whole OCI -# index (all platform manifests and referrers) byte-for-byte. Do NOT switch -# this to `docker buildx imagetools create` — it can rewrite the index and -# change its digest, breaking the sender's verification. -# -# The payload arrives with whatever authority holds the dispatch token, so it -# is validated as untrusted input: names are pattern-checked, source and -# destination are derived here rather than trusted from the payload, and the -# source must resolve to the digest claimed by the sender before anything is -# copied. +# The payload arrives with whatever authority holds the dispatch token, so it is treated as +# untrusted: names are pattern-checked, source/destination are derived here rather than trusted +# from the payload, and the source must resolve to the claimed digest before anything is copied. # # Full contract: docs/design/ecr-mirror-dispatch.md in supabase/slim-services. @@ -103,8 +94,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Install regctl - # Installed under $RUNNER_TEMP (always writable by the job user) and - # exposed to later steps via $GITHUB_PATH. + # $RUNNER_TEMP is always writable by the job user. run: | set -euo pipefail install -d "${RUNNER_TEMP}/regctl-bin" @@ -174,16 +164,10 @@ jobs: SOURCE: ${{ steps.validate.outputs.source }} DESTINATION: ${{ steps.validate.outputs.destination }} DIGEST: ${{ steps.validate.outputs.digest }} - # Copy by digest so the copy cannot race a tag move on the source; the - # whole index, all child manifests, and any referrers move as-is. - # - # The copy runs unconditionally, with no early exit when the - # destination tag already resolves to the digest: regctl's copy is - # incremental, so a re-dispatch after a complete copy is a cheap - # verification pass, while re-running after a partial failure (root - # index pushed but referrers or digest-tags missing) completes the - # copy instead of skipping it. Re-dispatches therefore still exit - # successfully with the destination digest unchanged. + # Copies by digest (not tag) so it can't race a tag move on the source, and runs + # unconditionally with no early exit: regctl's copy is incremental, so re-dispatching + # after a complete copy is a cheap no-op, and after a partial failure it finishes the + # copy instead of skipping it. run: | set -euo pipefail regctl image copy --referrers --digest-tags \ diff --git a/.github/workflows/release-config.yml b/.github/workflows/release-config.yml index c002f39c8f..d3d2bc24ff 100644 --- a/.github/workflows/release-config.yml +++ b/.github/workflows/release-config.yml @@ -7,16 +7,12 @@ on: paths: - "packages/config/**" - ".github/workflows/release-config.yml" - # workflow_dispatch is the manual re-cut path, mirroring the CLI's Release - # workflow. Defaults to `true` so a stray "Run workflow" click can't - # accidentally publish — operators must consciously untick this. + # workflow_dispatch is the manual re-cut path, mirroring the CLI's Release workflow. Defaults + # to `true` so a stray "Run workflow" click can't accidentally publish. # - # There is deliberately no `version` input: the publish job's registry probe - # skips versions that already exist on npm (after verifying the registry - # bytes match the reviewed artifact), so recovery from stale published - # bytes is "land a new (releasable) commit" — with no binary artifacts and a - # human approval in the loop, the CLI's cut-forward escape hatch isn't worth - # a second code path here. + # There is no `version` input: the publish job's registry probe already skips versions that + # exist on npm with matching bytes, so recovery from stale published bytes is just landing a + # new releasable commit — not worth a second cut-forward code path here. workflow_dispatch: inputs: dry_run: @@ -45,11 +41,9 @@ jobs: npm_tag: ${{ steps.plan.outputs.npm_tag }} dry_run: ${{ steps.plan.outputs.dry_run }} steps: - # semantic-release runs `git push --dry-run HEAD:` as part of - # verifyAuth even in `dry_run: true` mode, so the token must have push - # access to the protected `develop` branch. The default GITHUB_TOKEN - # doesn't, so we mint an App-installation token from the same App used - # by the CLI's release pipeline. + # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop branch — + # the default GITHUB_TOKEN doesn't have it. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -57,11 +51,9 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - # `persist-credentials: false` is required: otherwise checkout caches the - # default GITHUB_TOKEN as an `http.extraheader` in git config, and that - # Authorization header overrides the App token semantic-release puts in - # the push URL — making the dry-push identify as `github-actions[bot]` - # and get rejected by branch protection. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -101,20 +93,16 @@ jobs: trap 'echo "::${resume_token}::"' EXIT pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md" - # The build, gate, and pack steps also run on private-blocked pushes - # (should_release=false, version set): if `private` were ever flipped - # back on, every config push would still rehearse the plan half of the - # release train while the publish half stays parked. + # Build/gate/pack also run on private-blocked pushes (should_release=false, version set): + # if `private` is ever flipped back on, the plan half of the release train is already + # rehearsed on every config push. - name: Build @supabase/config if: steps.plan.outputs.version != '' run: pnpm exec turbo run @supabase/config#build - # Pack the exact tarball the approver's evidence (the gate summary - # below) describes. The publish job publishes THIS artifact rather than - # rebuilding: builds are not byte-reproducible across jobs (see - # release-shared.yml's brew/scoop cache-key comments for how that bit - # once before), and a rebuild would mean the approved bytes and the - # published bytes can differ. + # Packs the exact tarball the approver's evidence (the gate summary below) describes, + # rather than rebuilding: builds aren't byte-reproducible across jobs, so a rebuild could + # let the approved bytes and published bytes differ. - name: Pack the release tarball if: steps.plan.outputs.version != '' env: @@ -127,11 +115,9 @@ jobs: pnpm pack --pack-destination "$RUNNER_TEMP/config-release-artifact" cp "$RUNNER_TEMP/config-release-notes.md" "$RUNNER_TEMP/config-release-artifact/" - # The gate diffs the declarations INSIDE the packed tarball (not the - # dist/ build directory it was packed from), so the approver's evidence - # is generated from the same bytes the publish job ships — a packlist - # regression that drops .d.ts files from the tarball fails here instead - # of shipping a surface the approver never saw. + # Diffs the declarations inside the packed tarball (not the dist/ directory it was packed + # from), so a packlist regression that drops .d.ts files fails here instead of shipping a + # surface the approver never saw. - name: Run type-surface release gate against the packed tarball if: steps.plan.outputs.version != '' env: @@ -153,13 +139,10 @@ jobs: if-no-files-found: error retention-days: 7 - # The `config-release` environment's required-reviewers rule is repo - # configuration, not code: an environment referenced by a workflow is - # auto-created WITHOUT protection rules, in which case the publish job - # would run straight through unreviewed. Fail closed here — before a - # real (non-dry) release can reach the publish job — if the rule is - # missing or unreadable. Private-blocked rehearsals (should_release - # false) are unaffected — this only gates real releases. + # An environment referenced by a workflow is auto-created without protection rules, so if + # `config-release`'s required-reviewers rule (repo config, not code) goes missing, the + # publish job would run straight through unreviewed. Fail closed here before a real + # (non-dry) release can reach it; private-blocked rehearsals are unaffected. - name: Assert the release approval gate is armed if: steps.plan.outputs.should_release == 'true' && steps.plan.outputs.dry_run != 'true' env: @@ -194,26 +177,21 @@ jobs: name: Publish needs: plan if: needs.plan.outputs.should_release == 'true' && needs.plan.outputs.dry_run != 'true' - # npm provenance verification rejects non-GitHub-hosted runners with - # E422 ("Unsupported GitHub Actions runner environment: self-hosted"). - # Blacksmith runners count as self-hosted from sigstore's POV, so the - # publish job must stay on a github-hosted runner. The job is short and - # not compute-bound, so the wall-clock cost is negligible. + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest timeout-minutes: 30 environment: name: config-release - # This environment must be configured with required reviewers in repo - # settings (asserted by the plan job above). The approver reviews the - # plan job's step summary (release notes + type-surface gate diff) - # before approving — that approval IS the hard semver gate, and the - # tarball published below is byte-identical to the one that evidence - # was generated from. + # Configured with required reviewers in repo settings (asserted by the plan job above). The + # approver reviews the plan job's step summary (release notes + type-surface gate diff) + # before approving — that approval is the hard semver gate, and the tarball published below + # is byte-identical to what that evidence was generated from. url: https://www.npmjs.com/package/@supabase/config/v/${{ needs.plan.outputs.version }} - # OIDC trusted publishing + provenance — same as release-shared.yml; no - # NPM_TOKEN anywhere. This job deliberately runs NO dependency install and - # NO build: the only repo code it executes is this workflow file, keeping - # arbitrary package code away from the job that holds id-token: write. + # OIDC trusted publishing + provenance — same as release-shared.yml; no NPM_TOKEN anywhere. + # This job runs no dependency install and no build: the only repo code it executes is this + # workflow file, keeping arbitrary package code away from the job that holds id-token: write. permissions: contents: write id-token: write @@ -237,9 +215,8 @@ jobs: persist-credentials: true token: ${{ steps.app-token.outputs.token }} - # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 is - # required for OIDC trusted publishing, newer than the runner image's - # system npm, and node 24 bundles it. + # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 (required for OIDC + # trusted publishing) is newer than the runner image's system npm; node 24 bundles it. - name: Install node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -267,12 +244,9 @@ jobs: exit 1 fi - # Idempotent, mirroring publish.ts's registry-probe intent: a re-run - # after a post-publish failure must not die on EPUBLISHCONFLICT — but - # the skip is only safe when the registry's bytes ARE the reviewed - # artifact, since the tag push below would otherwise bless foreign - # bytes (e.g. a previous run's tag push failed, new commits landed, - # and a fresh plan recomputed the same version from a newer tree). + # Idempotent, mirroring publish.ts's registry-probe intent: a re-run after a post-publish + # failure must not die on EPUBLISHCONFLICT. The skip is only safe when the registry's bytes + # match the reviewed artifact, since the tag push below would otherwise bless foreign bytes. - name: Publish to npm working-directory: ${{ runner.temp }}/config-release run: | @@ -298,18 +272,12 @@ jobs: npm publish "./${tarball}" --ignore-scripts --provenance --tag "${NPM_TAG}" fi - # "Published successfully" for the notification jobs below means - # REGISTRY-VISIBLE with the reviewed bytes: probe npm until the version - # resolves (the registry can lag a publish by a few seconds) and its - # integrity matches the reviewed tarball. Runs before the tag push so a - # tag is never blessed for bytes this run couldn't confirm on the - # registry; the dist-tag assertion deliberately lives AFTER the tag push - # so a dist-tag propagation hiccup can't strand the release live on npm - # with origin untagged. A transient failure here is safe to re-run — the - # publish step's registry probe skips the republish and this check - # repeats — though a re-run of this job re-arms the config-release - # approval gate and costs a second human approval — hence the generous - # (~2 minute) visibility budget. + # "Published successfully" for the notification jobs means registry-visible with the + # reviewed bytes: probe npm until the version resolves and its integrity matches. Runs + # before the tag push so a tag is never blessed for unconfirmed bytes; the dist-tag + # assertion instead lives after the tag push so a propagation hiccup can't strand the + # release published but origin-untagged. A re-run of this job re-arms the approval gate and + # costs a second human approval, hence the generous (~2 minute) visibility budget. - name: Verify the release is live on npm working-directory: ${{ runner.temp }}/config-release run: | @@ -347,12 +315,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Push the tag to origin as soon as npm has the bytes, before any - # downstream step that can fail. Without this, a failure in the GH - # release step leaves origin with no tag for the version that is now - # live on npm — and a subsequent plan would recompute the same version - # against stale bytes. Idempotent: skips push if the tag is already on - # origin (e.g. a re-run of a job that previously got past this step). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and a + # subsequent plan would recompute the same version against stale bytes. Idempotent: skips + # if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -364,11 +330,10 @@ jobs: git push origin "${tag}" fi - # Asserted after the tag push on purpose: npm already holds the reviewed - # bytes (verified above), so a dist-tag propagation hiccup must not - # strand the release npm-published but origin-untagged. Retries until - # the tag points at THIS version — a stale packument still echoing the - # previous version is a retryable state, not a terminal mismatch. + # Asserted after the tag push on purpose: npm already holds the reviewed bytes (verified + # above), so a dist-tag propagation hiccup must not strand the release npm-published but + # origin-untagged. Retries until the tag points at this version, since a stale packument is + # a retryable state, not a terminal mismatch. - name: Verify the npm dist-tag run: | set -euo pipefail @@ -406,13 +371,10 @@ jobs: # become the repo's "latest" release. make_latest: "false" - # Pages the release Slack channel the moment a real run arms the - # config-release approval gate. This job only needs `plan`, so it runs in - # parallel with the publish job's `waiting` state — the ping and the pending - # deployment appear together. The approval itself stays on GitHub: the run - # page hosts the Approve button and the plan job's evidence summary; the - # webhook is one-way and cannot host an interactive approval. Nothing - # depends on this job, so a Slack/webhook failure can't block the release. + # Pages Slack the moment a real run arms the config-release approval gate. Runs in parallel + # with the publish job's `waiting` state (needs only `plan`), so the ping and the pending + # deployment appear together — the approval itself stays on GitHub, since the webhook is + # one-way and can't host an interactive approval. notify-slack-approval: name: Notify Slack (approval needed) needs: plan @@ -426,12 +388,9 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Posts once the publish job has verified the release is registry-visible - # with the reviewed bytes. No `if:` needed: the implicit success() gate - # means this only runs when plan and publish both succeeded, and publish - # itself only runs for real (non-dry) releases — dry runs and no-release - # pushes skip publish, which skips this too. Nothing depends on this job, - # so a Slack/webhook failure can't affect the already-completed release. + # Posts once publish has verified the release is registry-visible with the reviewed bytes. No + # `if:` needed: the implicit success() gate means this only runs when plan and publish both + # succeeded, and publish itself only runs for real releases. notify-slack: name: Notify Slack needs: [plan, publish] @@ -445,14 +404,10 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Distinguishes "a reviewer rejected the pending deployment" from a real - # pipeline failure before paging the channel: a rejection marks the publish - # job failed, and announcing that as a broken release would page people - # about a deliberate decision. The run's approvals record is the only place - # the distinction is visible from inside the workflow. The dry-run guard - # reads the dispatch input directly rather than plan's output, so a plan - # job that dies before recording dry_run still can't page for an - # operator-watched dry run. + # Distinguishes a reviewer's rejection from a real pipeline failure before paging the channel — + # a rejection marks the publish job failed, and announcing that as a broken release would page + # people about an intentional call. The dry-run guard reads the dispatch input directly, so a + # plan job that dies before recording `dry_run` still can't page for an operator-watched run. classify-failure: name: Classify failure needs: [plan, publish] @@ -493,14 +448,10 @@ jobs: echo "status=failure" >> "$GITHUB_OUTPUT" fi - # Reports a failed (or reviewer-declined) release. `failure()` on the - # classify job evaluates against its `needs` chain, so this pair fires - # whenever `plan` or `publish` fails — including an approval rejection — - # but stays quiet for dry runs and no-release pushes (skipped needs don't - # count as failures). When `plan` fails its outputs are empty, so the - # message falls back to the workflow run link as the actionable detail. - # Fails open: if the classifier itself breaks, the page still goes out as a - # plain failure (its status output is empty, so the expression falls back). + # Reports a failed (or reviewer-declined) release. `failure()` on the classify job evaluates + # against its `needs` chain, so this pair fires whenever `plan` or `publish` fails — including + # an approval rejection — but stays quiet for dry runs and no-release pushes. Fails open: if + # the classifier itself breaks, the page still goes out as a plain failure. notify-slack-failure: name: Notify Slack (failure) needs: [plan, publish, classify-failure] diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 5db584e874..57b1d3e8a5 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -119,12 +119,10 @@ jobs: enableCrossOsArchive: true fail-on-cache-miss: true - # Docker's classic image store keeps a single platform manifest per - # tag, so pulling `alpine:3.21` for amd64 and again for arm64 leaves - # only the most recent one in the local store — and `docker save` - # captures only that one. Enabling the containerd snapshotter lets - # the store hold multi-platform manifests under a single tag, which - # is what makes the cache step below work for both arches. + # Docker's classic image store keeps a single platform manifest per tag, so pulling + # `alpine:3.21` for amd64 then arm64 leaves only the most recent in the local store, and + # `docker save` captures only that one. The containerd snapshotter lets the store hold + # multi-platform manifests under a single tag instead. - name: Enable containerd image store if: runner.os == 'Linux' run: | @@ -133,17 +131,12 @@ jobs: sudo systemctl restart docker docker info --format '{{.DriverStatus}}' - # The host's binfmt_misc must be mounted BEFORE installing QEMU. On cold - # Blacksmith VMs it is not mounted by default; the privileged - # tonistiigi/binfmt installer that setup-qemu-action runs then registers - # the qemu interpreters inside its own mount namespace — it prints - # "installing: arm64 OK" but the handlers vanish when the container exits, - # so the host kernel never gains arm64 emulation and every - # `docker run --platform linux/arm64` dies with "exec format error". - # Warm/reused VMs already had it mounted, which is why this only failed - # intermittently (on cache-miss, i.e. cold-VM, runs). Mount it on the host - # after the docker restart so the registration lands in the host kernel - # and persists through to the smoke tests. + # The host's binfmt_misc must be mounted before installing QEMU. On cold Blacksmith VMs it + # isn't mounted by default, so setup-qemu-action's privileged installer registers the qemu + # interpreters inside its own mount namespace — they vanish when the container exits, and + # `docker run --platform linux/arm64` fails with "exec format error" (only on cold-VM runs, + # since warm VMs already have it mounted). Mount it on the host after the docker restart so + # the registration lands in the host kernel. - name: Ensure binfmt_misc is mounted on the host if: runner.os == 'Linux' run: | @@ -167,11 +160,10 @@ jobs: # the host kernel. Reads binfmt_misc directly — no image pull, no network. grep -q enabled /proc/sys/fs/binfmt_misc/qemu-aarch64 - # Cache the smoke-test base images across runs. Without this, eight - # parallel `docker run` calls in smoke-test-linux.ts race on first-time - # pulls and surface as docker exit 125 ("daemon could not start the - # container") on a subset of jobs. Bump the `-v` suffix to invalidate - # the cache when the image list or tags change. + # Caches the smoke-test base images across runs — without it, eight parallel `docker run` + # calls in smoke-test-linux.ts race on first-time pulls and fail with docker exit 125 + # ("daemon could not start the container"). Bump the `-v` suffix to invalidate when the + # image list or tags change. - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache @@ -219,9 +211,8 @@ jobs: run: chmod +x packages/cli-*/bin/supabase || true - name: Run smoke tests - # Force bash so ${VERSION}/${NPM_TAG} expand identically across the - # ubuntu/macos/windows matrix — windows-latest defaults to pwsh, which - # treats those as empty PowerShell variables (env vars are `$env:VAR`). + # Force bash so ${VERSION}/${NPM_TAG} expand identically across the ubuntu/macos/windows + # matrix — windows-latest defaults to pwsh, which treats those as empty PowerShell variables. shell: bash run: pnpm run test:smoke -- --version "${VERSION}" --tag "${NPM_TAG}" working-directory: apps/cli @@ -269,7 +260,6 @@ jobs: uses: ./.github/workflows/live-e2e-gate.yml with: sha: ${{ github.sha }} - # Forward the one secret required by live-e2e-gate.yml. secrets: SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} @@ -280,11 +270,9 @@ jobs: - smoke-test-macos - live-e2e-gate if: ${{ !cancelled() && !inputs.dry_run && needs.build-github.result == 'success' && needs.smoke-test.result == 'success' && needs.smoke-test-macos.result == 'success' && (inputs.channel != 'stable' || needs.live-e2e-gate.result == 'success') }} - # npm provenance verification rejects non-GitHub-hosted runners with - # E422 ("Unsupported GitHub Actions runner environment: self-hosted"). - # Blacksmith runners count as self-hosted from sigstore's POV, so the - # publish job must stay on a github-hosted runner. The job is short - # and not compute-bound, so the wall-clock cost is negligible. + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -339,13 +327,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Push the version tag to origin as soon as npm has the bytes, before any - # downstream step that can fail. Without this, a failure in the GH-release - # step (or anything after) leaves origin with no tag for the version that - # is now live on npm — and `semantic-release --dry-run` on the next push - # will keep recomputing the same version, causing the publish job to no-op - # against stale bytes. Idempotent: skips push if the tag is already on - # origin (e.g. a re-run of a job that previously got past this step). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and + # `semantic-release --dry-run` on the next push keeps recomputing the same version. + # Idempotent: skips if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -357,19 +342,16 @@ jobs: git push origin "${tag}" fi - # semantic-release matches tags against branch.channel using git-notes - # under refs/notes/semantic-release-. Without this note, the develop - # plan job recomputes the same prerelease version every push (see - # docs/ci-beta-tags-investigation.md). Stable releases on main don't - # need this — main's branch config has no explicit channel. + # semantic-release matches tags against branch.channel using git-notes under + # refs/notes/semantic-release-; without this note, the develop plan job recomputes + # the same prerelease version every push (see docs/ci-beta-tags-investigation.md). Stable + # releases on main don't need this, since main's branch config has no explicit channel. # - # The note must be attached to the underlying commit, not the annotated - # tag object created by "Push version tag" above. semantic-release reads - # notes via `git log --no-walk --tags=* --notes=...`, which looks for - # notes on commits; a note on the tag object is invisible to it and - # leaves the tag with channels:[null], so the prerelease arm of - # getLastRelease's filter rejects it and the plan falls back to the - # last stable. Hence the `${tag}^{commit}` deref. + # The note must attach to the underlying commit, not the annotated tag object created by + # "Push version tag" above — semantic-release reads notes via `git log --no-walk + # --tags=* --notes=...`, which looks at commits, so a note on the tag object leaves the tag + # with channels:[null] and the plan falls back to the last stable. Hence the + # `${tag}^{commit}` deref. - name: Push channel note for prerelease tags if: ${{ inputs.prerelease }} run: | @@ -383,10 +365,9 @@ jobs: git push origin "${note_ref}" fi - # goreleaser used to publish both supabase___.tar.gz and - # supabase__.tar.gz; setup-cli, install scripts, docs and various - # third-party tools point at releases/latest/download/.tar.gz. - # Re-publish the alias so those callers keep working. See supabase/cli#5257. + # setup-cli, install scripts, docs, and other tools reference + # releases/latest/download/.tar.gz, so republish that alias alongside the + # versioned tarball. - name: Create unversioned tarball aliases run: | set -euo pipefail @@ -465,11 +446,10 @@ jobs: secrets: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} - # Once the raw semantic-release block is in the release body, ask Claude to - # rewrite it into user-centric notes and open a PR for human approval. Stable - # releases only on this path — prereleases keep the raw body. Non-blocking so - # an LLM hiccup never gates a published release; reviewers can propose beta - # notes manually from the Actions tab (workflow_dispatch). + # Once the raw semantic-release block is in the release body, asks Claude to rewrite it into + # user-centric notes and open a PR for human approval. Stable only — prereleases keep the raw + # body. Non-blocking so an LLM hiccup never gates a published release; reviewers can propose + # beta notes manually via workflow_dispatch. propose-release-notes: uses: ./.github/workflows/propose-release-notes.yml needs: backfill-release-notes @@ -503,12 +483,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here produced a formula whose sha256 rejected the - # downloaded archive ("Formula reports different checksum"). + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a formula built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -566,12 +544,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here would produce a manifest whose hash rejects the - # downloaded archive. + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a manifest built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -608,18 +584,12 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against - # the just-released CLI. Runs last and intentionally does not gate - # publish-homebrew / publish-scoop — by the time the smoke runs, the npm - # package and GitHub release are already live and the brew/scoop pushes - # have either succeeded or skipped, so a setup-cli regression surfaces as - # a red post-release signal without holding back the rest of the channel. - # - # Depends on the publish jobs only via `needs` for ordering; the `if` - # uses `always() && needs.publish.result == 'success'` so the smoke still - # runs when publish-homebrew / publish-scoop are skipped or fail. - # The reusable workflow can also be dispatched manually against any - # already-published version when debugging setup-cli regressions. + # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against the just-released + # CLI. Runs last and intentionally doesn't gate publish-homebrew/publish-scoop — by the time it + # runs, the npm package and GitHub release are already live, so a setup-cli regression surfaces + # as a red post-release signal without holding back the rest of the channel. `needs` is for + # ordering only; `always() && needs.publish.result == 'success'` lets it run even when + # publish-homebrew/publish-scoop are skipped or fail. setup-cli-smoke: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && needs.publish.result == 'success' }} @@ -627,16 +597,11 @@ jobs: with: version: ${{ inputs.version }} - # Post-publish end-to-end check that the Homebrew tap, Scoop bucket, and the - # curl|bash install script actually install the just-released CLI. brew/scoop - # verify the published checksum against the downloaded tarball, so this is the - # signal that would have caught CLI v2.107.0 (mismatched brew/scoop sha256s). - # - # Only runs when brew/scoop were published (beta/stable) and both pushes - # succeeded. A channel that skipped brew/scoop entirely is covered by the GitHub - # Release download path in setup-cli-smoke. Like setup-cli-smoke, it runs last - # and does not gate the rest of the channel: by the time it runs the manifests - # are already live, so a failure surfaces as a red post-release signal. + # Post-publish check that the Homebrew tap, Scoop bucket, and curl|bash install script + # actually install the just-released CLI — brew/scoop verify the published checksum against + # the downloaded tarball, catching a mismatch like CLI v2.107.0. Only runs when brew/scoop were + # published and both pushes succeeded; a skipped channel is covered by the GitHub Release + # download path in setup-cli-smoke. Runs last and doesn't gate the rest of the channel. verify-install-channels: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && inputs.publish_brew_scoop && needs.publish-homebrew.result == 'success' && needs.publish-scoop.result == 'success' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd0b55a10..7f043e20c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,18 +25,16 @@ on: description: npm package version to publish (must be unique on npm; pick the next unused version) required: true type: string - # Defaults to `true` so a stray "Run workflow" click can't accidentally - # publish — operators recovering from a stale-bytes run must consciously - # untick this when dispatching. + # Defaults to `true` so a stray "Run workflow" click can't accidentally publish; operators + # must consciously untick this when dispatching. dry_run: description: Dry run (skip actual publishing) required: false type: boolean default: true -# The release pipeline mutates external package registries, git tags, GitHub -# Releases, Homebrew, and Scoop. Keep one release per ref active at a time so -# duplicate push events cannot race the same computed version. +# The release pipeline mutates external registries, git tags, GitHub Releases, Homebrew, and +# Scoop, so keep one release per ref active at a time to avoid duplicate push events racing. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -90,11 +88,9 @@ jobs: dry_run: ${{ steps.compute.outputs.dry_run }} channel: ${{ steps.compute.outputs.channel }} steps: - # semantic-release runs `git push --dry-run HEAD:` as part of - # verifyAuth even in `dry_run: true` mode, so the token must have push - # access to the protected `develop`/`main` branches. The default - # GITHUB_TOKEN doesn't, so we mint an App-installation token from the - # same App used for fast-forward + brew/scoop pushes. + # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop/main + # branches — the default GITHUB_TOKEN doesn't have it. - id: app-token if: github.event_name == 'push' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -102,11 +98,9 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - # `persist-credentials: false` is required: otherwise checkout caches the - # default GITHUB_TOKEN as an `http.extraheader` in git config, and that - # Authorization header overrides the App token semantic-release puts in - # the push URL — making the dry-push identify as `github-actions[bot]` - # and get rejected by branch protection. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -185,11 +179,9 @@ jobs: name: Release needs: plan if: needs.plan.outputs.should_release == 'true' - # pull-requests: write is required by the nested propose-release-notes - # workflow (release-shared.yml -> propose-release-notes.yml). For nested - # reusable workflows, a called job's permissions can't exceed those granted - # to the calling job, so this must be declared here even though the propose - # job uses an App token for its actual PR creation. + # pull-requests: write is required by the nested propose-release-notes workflow + # (release-shared.yml -> propose-release-notes.yml): a called job's permissions can't exceed + # those granted to the calling job, so this must be declared here too. permissions: actions: read contents: write @@ -216,12 +208,9 @@ jobs: LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - # Republishes the supabase.com CLI reference, restoring the job the Go - # `tools/bumpdoc` ran before the monorepo merge (71b543255) deleted it — the - # published spec has been frozen at 2.98.2 since. Stable only: the reference - # documents the CLI users actually install, so pre-releases are skipped. - # Nothing depends on this job, so a docs-site failure cannot affect the - # already-completed release. + # Republishes the supabase.com CLI reference. Stable only: the reference documents the CLI + # users actually install, so pre-releases are skipped. Nothing depends on this job, so a + # docs-site failure cannot affect the already-completed release. docs: name: Publish reference docs needs: [plan, release] @@ -255,9 +244,8 @@ jobs: run: gh auth setup-git - name: Publish the CLI reference working-directory: apps/cli - # `shell: bash` for `-o pipefail`: the default `run:` shell would take - # the exit status of the publisher alone, so a generator that died - # part-way would look like a successful run of a truncated spec. + # `shell: bash` for `-o pipefail`: the default `run:` shell takes only the publisher's + # exit status, so a generator that died part-way would look like a successful run. shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -271,12 +259,10 @@ jobs: bun scripts/generate-docs-spec.ts "$VERSION" \ | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" - # Posts to the release Slack channel once the pipeline succeeds. Listing - # `release` in `needs` without a status function in `if:` keeps the implicit - # success() gate, so this only runs when both plan and release succeeded. - # The `if:` then filters to real (non-dry-run) stable cuts; beta and dry - # runs stay silent. Nothing depends on this job, so a Slack/webhook - # failure can't affect the already-completed release. + # Listing `release` in `needs` without a status function in `if:` keeps the implicit success() + # gate, so this only runs when both plan and release succeeded; the `if:` then filters to real + # (non-dry-run) stable cuts. Nothing depends on this job, so a Slack failure can't affect the + # already-completed release. notify-slack: name: Notify Slack needs: [plan, release] @@ -292,15 +278,11 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Reports a broken release on every channel. `failure()` evaluates against the - # `needs` chain, so this fires whenever `plan` or `release` (and anything in - # the reusable release-shared workflow) fails. Skipped jobs — e.g. the - # fast-forward path or a release that never started — don't count as failures, - # so this stays quiet there. Dry runs are excluded — the guard reads the - # dispatch input directly, so even a plan job that dies before recording its - # dry_run output stays quiet; an operator running one is already watching it - # live. When `plan` fails its outputs are empty, so the message falls back to - # the workflow run link as the actionable detail. + # Reports a broken release on every channel. `failure()` evaluates against the full `needs` + # chain, so this fires whenever `plan` or `release` (including the reusable release-shared + # workflow) fails. Skipped jobs — e.g. the fast-forward path, or a release that never started — + # don't count as failures. Dry runs are excluded via the dispatch input directly, so even a + # plan job that dies before recording its `dry_run` output stays quiet. notify-slack-failure: name: Notify Slack (failure) needs: [plan, release] diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index 1725e4ada2..b0b17fcd30 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -1,13 +1,9 @@ name: setup-cli Smoke Test -# Smoke test for the `supabase/setup-cli` GitHub Action against a specific -# published CLI version. Run automatically after every beta release (called -# from release-shared.yml's `setup-cli-smoke` job) and also manually via -# workflow_dispatch when debugging setup-cli regressions or verifying that -# a previously broken environment (e.g. Alpine) is back to green. -# -# Exists primarily to catch regressions like supabase/setup-cli#427 where -# musl libc + archive layout changes broke the action on Alpine silently. +# Smoke test for the `supabase/setup-cli` GitHub Action against a specific published CLI +# version. Runs automatically after every beta release and also manually via workflow_dispatch +# when debugging setup-cli regressions. Catches regressions like supabase/setup-cli#427, where +# musl libc + archive layout changes silently broke the action on Alpine. on: workflow_call: @@ -36,9 +32,8 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - # GitHub Actions doesn't allow expressions in `uses:`, so the action - # ref is selected via two `if:`-gated install steps below rather than - # interpolating `matrix.major-version` into a single `uses:` line. + # GitHub Actions doesn't allow expressions in `uses:`, so the action ref is selected via + # two `if:`-gated install steps below instead of interpolating `matrix.major-version`. major-version: - v1 - v2 @@ -72,14 +67,10 @@ jobs: exit 1 fi - # Alpine leg of the setup-cli smoke test. Kept as a separate job because - # GitHub Actions only honours the `container:` field on Linux runners, and - # mixing container + non-container entries in a single matrix via - # `container: ${{ matrix.container }}` is fragile when the value is empty. - # - # The musl-vs-glibc + archive layout regressions tracked in - # supabase/setup-cli#427 only reproduce inside a real Alpine container, so - # this job is the actual signal the workflow was created for. + # A separate job because GitHub Actions only honours `container:` on Linux runners, and mixing + # container + non-container entries in one matrix is fragile when the value is empty. The + # musl-vs-glibc + archive layout regressions from supabase/setup-cli#427 only reproduce inside + # a real Alpine container. setup-cli-smoke-test-alpine: name: setup-cli ${{ matrix.major-version }} (alpine) strategy: @@ -90,9 +81,8 @@ jobs: - v2 runs-on: ubuntu-latest timeout-minutes: 10 - # `node:20-alpine` ships a musl-linked Node, which is required for - # JavaScript-based actions (setup-cli included) to launch inside an - # Alpine container — the runner's mounted glibc Node won't execute here. + # `node:20-alpine` ships a musl-linked Node, required for JavaScript-based actions to launch + # here — the runner's mounted glibc Node won't execute inside an Alpine container. container: image: node:20-alpine env: diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index cf5a6983bb..a3a94bc4fa 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -1,35 +1,14 @@ name: Verify Install Channels -# Post-publish end-to-end verification that the *published* install channels -# (Homebrew, Scoop, and the curl|bash install script) actually install the -# just-released CLI and serve artifacts whose checksums match what the channel -# manifests declare. Runs automatically after every brew/scoop publish (called -# from release-shared.yml's `verify-install-channels` job) and can also be -# dispatched manually against any already-published version when debugging an -# install regression. +# Post-publish verification that the published install channels (Homebrew, Scoop, curl|bash) +# install the just-released CLI and serve artifacts matching the channel manifests' checksums. +# Runs automatically after every brew/scoop publish, and can also be dispatched manually against +# an already-published version. # -# Exists primarily to catch regressions like CLI v2.107.0, where the Homebrew -# formula and Scoop manifest shipped sha256 checksums that did not match the -# tarballs on the GitHub Release, so `brew install` / `scoop install` failed -# for every user with "Formula reports different checksum". brew, scoop, and -# the install script all verify the declared checksum against the downloaded -# bytes before installing, so a real install reproduces that failure exactly -# instead of trusting the manifest the publish step wrote. -# -# Each leg goes beyond `supabase --version` (handled by the Bun wrapper without -# touching the sidecar) and directly checks that the `supabase-go` sidecar -# binary is present and executable in the channel's install directory, so a -# package that omits or misplaces it fails here instead of silently shipping -# a CLI whose still-Go-proxied commands (see docs/go-cli-porting-status.md) -# would fail for every user. -# -# This used to run `supabase completion bash`, since that command was -# Go-proxied. It no longer is (CLI-1965 ported shell completion to native -# TypeScript, and the Go CLI's own completion command was subsequently -# removed too), so that probe silently stopped testing the sidecar at all. -# Checking for the sidecar file directly instead of routing through some -# still-proxied command avoids repeating that mistake as more commands get -# natively ported. +# Performs a real install so a manifest/tarball checksum mismatch (as in CLI v2.107.0) reproduces +# here instead of being trusted from the publish step. Each leg also directly checks that the +# `supabase-go` sidecar binary is present and executable in the install directory, checking the +# file rather than a CLI command, since a command could later be natively ported off the sidecar. on: workflow_call: @@ -68,10 +47,8 @@ permissions: jobs: homebrew: - # macOS and Linux exercise different stanzas of the formula - # (`on_macos` vs `on_linux`), each with its own URL + sha256, so both must - # install for the tap to be considered verified. Homebrew is preinstalled - # on GitHub-hosted Ubuntu runners. + # macOS and Linux exercise different formula stanzas (`on_macos` vs `on_linux`), each with + # its own URL + sha256, so both must install for the tap to be considered verified. name: Homebrew ${{ inputs.brew_name }} (${{ matrix.runner }}) strategy: fail-fast: false @@ -84,14 +61,11 @@ jobs: env: VERSION: ${{ inputs.version }} BREW_NAME: ${{ inputs.brew_name }} - # Don't auto-update Homebrew itself before installing; `brew install - # //` taps supabase/homebrew-tap from its git HEAD - # regardless, so the freshly-pushed formula is picked up either way. + # Skips Homebrew's own auto-update; `brew install //` taps + # supabase/homebrew-tap from git HEAD regardless, so the fresh formula is picked up anyway. # - # NB: do NOT set HOMEBREW_NO_INSTALL_FROM_API here. It only affects the - # homebrew/core + cask taps (third-party taps are always read from git), - # and on Linux runners it forces a large, slow local checkout of - # homebrew/core that makes this job hang for 10+ minutes. + # Do not also set HOMEBREW_NO_INSTALL_FROM_API: it only affects homebrew/core + cask taps, + # and on Linux it forces a slow homebrew/core checkout that hangs this job for 10+ minutes. HOMEBREW_NO_AUTO_UPDATE: "1" steps: - name: Set up Homebrew on PATH @@ -205,9 +179,8 @@ jobs: scoop bucket add supabase https://github.com/supabase/scoop-bucket scoop install "supabase/$env:SCOOP_NAME" - name: Verify supabase --version - # Force bash so ${VERSION} expands the same way it does on the other - # legs — windows-latest defaults to pwsh, which treats it as an empty - # PowerShell variable (env vars are `$env:VAR`). + # Force bash so ${VERSION} expands the same way as the other legs — windows-latest + # defaults to pwsh, which treats it as an empty PowerShell variable. shell: bash run: | set -euo pipefail diff --git a/.oxlintrc.json b/.oxlintrc.json index 42deffc4f2..1a78e4aec9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,11 +15,10 @@ "**/.source" ], "rules": { - // Spreading an Effect Data.Class instance into its constructor is the - // idiomatic copy-with-update pattern (`new State({ ...state, ... })`). + // `new State({ ...state, ... })` is the idiomatic Data.Class copy-with-update pattern. "typescript/no-misused-spread": "off", - // Effect combinators are passed as references by design, e.g. - // `.pipe(semaphore.withPermit)`; they do not rely on `this`. + // Effect combinators like `.pipe(semaphore.withPermit)` are passed as references by + // design and don't rely on `this`. "typescript/unbound-method": "off", // Too strict for CLI string building; `no-base-to-string` still guards // against `[object Object]` interpolation bugs. @@ -35,13 +34,9 @@ } }, { - // `.github/scripts` is the only `bun:test` consumer in the repo (every - // package test suite uses vitest). `@types/bun`'s test matcher types - // reuse the same sync `Matchers` interface for `expect(x).rejects`, - // so `.rejects.toThrow(...)` types as returning `void` even though it - // must be awaited at runtime — a `@types/bun` typing gap, not a real - // `await`-of-non-Promise bug. Verified: `bun test` and `tsc --noEmit` - // both pass; removing the `await` would make the assertion racy. + // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers` + // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at + // runtime. Removing the `await` would make the assertion racy. "files": [".github/scripts/**"], "rules": { "typescript/await-thenable": "off" diff --git a/AGENTS.md b/AGENTS.md index 55b4edf074..67454f028a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,6 +237,54 @@ pnpm test If a workspace exposes a different script set, use that workspace's `package.json` as the source of truth. +## Comments + +Comments exist for the next reader, not as the author's audit trail. Code states what happens; a comment states only the why that the code cannot carry. Most code needs no comment at all. + +### When to comment + +Write a comment only for one of these: + +- An invariant or constraint the types cannot express and that a later edit could silently break. +- A workaround for an external quirk (Docker, Postgres, an OS, a library), naming the symptom it avoids. +- A decision that would otherwise read as a bug, in one or two sentences. +- A pointer to where the full rationale lives: an ADR under `docs/adr/`, a `SIDE_EFFECTS.md`, a `docs/` page, or an upstream issue URL. + +If the rationale needs more than three lines, it does not belong in a comment. Move it to an ADR or a docs page and leave a one-line pointer. + +### How to comment + +- Prefer JSDoc (`/** … */`) on exported symbols. Lead with a one-sentence summary. Use `@param`, `@returns`, `@throws`, `@see`, `@deprecated`, `@example`, and `{@link}` where they carry the information more compactly than prose; skip `@param`/`@returns` when the name and type already say it. +- Skip JSDoc on internal helpers whose name and signature are self-explanatory. A helper that needs a paragraph to explain wants a better name or a smaller scope, not a comment. +- Keep inline `//` comments to one or two lines, placed above the statement they explain. +- Describe behavior in its own terms and in the present tense. +- Published packages (`@supabase/config`) ship JSDoc to consumers through `.d.ts`, so every public export there carries a one-line summary. +- This repo is public. Nothing internal (see "Pull Requests") goes into a comment. + +### Never write + +- Narration of the code: "loop over the rows", "return early if empty", "call the API". +- Provenance and history: Go file:line citations, commit SHAs, PR numbers, review-round notes, "previously", "no longer", "was renamed", "deleted in …". Git holds history. +- Evidence trails: "verified empirically", "confirmed by grepping every call site", "surveyed N call sites". Encode the evidence as a test instead. +- Ticket IDs as provenance. `CLI-1234` belongs only in a `TODO(CLI-1234):` or when it is the only home a decision has and no ADR exists. +- Emphasis: ALL-CAPS words, "deliberately", "DELIBERATE divergence", "crucially", "exactly". State the constraint once, plainly. +- Meta-commentary on the code's own shape: "not exported, callers reference it structurally", "hoisted to file scope so both suites share it", "kept separate on purpose". +- Section banners such as `// ---- Helpers ----`. Reorder or split the file instead. +- Restatements of docs. If a `SIDE_EFFECTS.md` or an ADR already explains it, link it. +- Go-parity framing. See "Source of Truth" in `apps/cli/AGENTS.md`. + +### Tests + +The test name carries the intent. Comment only non-obvious fixture setup, in one line. Do not annotate assertions with the reasoning behind them; put that reasoning into the test name or a more specific assertion. + +### Directive comments + +`// oxlint-disable-next-line`, `// @ts-expect-error`, `/// `, shebangs, and similar are instructions to tools, not prose. Keep them, and give every lint disable a short reason after `--`. The same goes for tool-facing JSDoc tags: `@public` tells knip an export is intentionally unused, `@internal` and `@deprecated` carry meaning for consumers. Keep them even when trimming the rest of a doc comment. + +### Size check + +A source file whose comment lines exceed a quarter of its code lines almost certainly holds prose that belongs in docs. Trim it before merging. + ## Workspace graph and task execution This repo uses pnpm workspaces and Turbo for task execution and dependency diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index a5a734f28a..b419eed1b1 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -22,12 +22,8 @@ * - Terminate ('X') → closes connection gracefully */ -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - export interface PgFixture { - /** Lowercase column names matching Go Result struct field names. */ + /** Lowercase column names, matching the field names the inspect commands map query results onto. */ columns: string[]; /** * Per-column Postgres type OIDs (text format is used throughout). @@ -60,10 +56,6 @@ export interface PgMockHandle { stop(): void; } -// --------------------------------------------------------------------------- -// Wire protocol helpers -// --------------------------------------------------------------------------- - const TEXT_OID = 25; const SSL_REQUEST_CODE = 80877103; const PROTOCOL_VERSION_3 = 196608; @@ -218,10 +210,6 @@ function buildCloseComplete(): Buffer { return msg(0x33, Buffer.alloc(0)); // '3' } -// --------------------------------------------------------------------------- -// Socket state -// --------------------------------------------------------------------------- - interface SocketData { buf: Buffer; phase: "startup" | "query"; @@ -229,10 +217,6 @@ interface SocketData { paramCount: number; } -// --------------------------------------------------------------------------- -// Response builders -// --------------------------------------------------------------------------- - function buildStartupResponse(): Buffer { return Buffer.concat([ buildAuthOk(), @@ -247,12 +231,10 @@ function buildStartupResponse(): Buffer { } /** - * Connection liveness probe (`SELECT 1`). The native TS driver eagerly forces its - * lazily-connected `pg` client by running `SELECT 1` over the simple-query - * protocol (Go's pgx connects eagerly at the protocol level and issues no such - * query). A real Postgres always answers `SELECT 1` regardless of any fixtures, so - * the mock must too — otherwise the empty-state "no fixture" guard rejects the - * probe and the native command fails to connect before doing any real work. + * Connection liveness probe (`SELECT 1`), run by the driver to force its lazy + * connection. A real Postgres always answers it regardless of fixtures, so the + * mock must too, or the empty-state "no fixture" guard would reject it before + * the command does any real work. */ function buildProbeResponse(): Buffer { return Buffer.concat([ @@ -342,10 +324,6 @@ function buildExecuteDataResponse(state: PgMockState): Buffer { return buildCommandComplete("SELECT 0"); } -// --------------------------------------------------------------------------- -// Message processing -// --------------------------------------------------------------------------- - function processMessages(socket: Bun.Socket, getState: () => PgMockState): void { while (true) { const { buf, phase } = socket.data; @@ -447,10 +425,6 @@ function processMessages(socket: Bun.Socket, getState: () => PgMockS } } -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- - export function startPgMock(): PgMockHandle { let state: PgMockState = { type: "empty" }; diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index 37dd711e00..62c832c569 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -29,7 +29,7 @@ const LEGACY_KEY_PREFIX_PATTERN = /"prefix": "[A-Za-z0-9]{4,10}"/g; const INTEGER_ID_PATTERN = /"id": \d{4,}/g; // A fixed valid ISO 8601 timestamp used in place of real timestamps so that -// CLI code that calls time.Parse on response fields doesn't fail. +// date-parsing code on response fields doesn't fail. const FIXED_TIMESTAMP = "2000-01-01T00:00:00Z"; /** Replace dynamic values in a string with stable, unnumbered placeholders. @@ -61,15 +61,11 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } -// The project-ref placeholder (`__PROJECT_REF__`) is 15 characters, but the -// Management API schema constrains project refs to `^[a-z]{20}$` (minLength 20). -// The Go CLI doesn't validate response bodies, so it tolerates the short -// placeholder; the TS port decodes responses against the generated schema and -// rejects it (e.g. `link` calling `getProject`). When serving a recorded -// response we therefore substitute any field whose value is *exactly* the -// placeholder back to a valid 20-char ref. Substring occurrences such as -// `db.__PROJECT_REF__.supabase.red` are left untouched so tests that assert on -// the literal placeholder host keep matching. +// `__PROJECT_REF__` is 15 characters, but the Management API schema requires +// `^[a-z]{20}$`, and response decoding rejects a value that doesn't match. When +// serving a recorded response, substitute any field whose value is *exactly* +// the placeholder back to a valid 20-char ref; leave substring occurrences such +// as `db.__PROJECT_REF__.supabase.red` untouched so placeholder-host assertions still match. const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 7b0491e2bb..23b4a869db 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -171,11 +171,9 @@ export async function startReplayServer(options: ReplayServerOptions): Promise { const isStoragePath = pathname.startsWith("/storage/v1/"); - // Docker versioned API paths start with /v1. (decimal) to distinguish from - // management API paths which start with /v1/ (slash). /_ping is the Docker - // health-check endpoint (no version prefix). + // Docker versioned API paths start with /v1. (decimal), not /v1/ (slash, the + // management API). /_ping is the Docker health-check endpoint (no version prefix). const isDockerPath = pathname.startsWith("/v1.") || pathname === "/_ping"; const FORWARD_HEADERS = new Set(["authorization", "content-type", "accept", "user-agent"]); @@ -575,8 +572,7 @@ function recordFixture(params: { request: FixtureRequest; response: FixtureResponse; }; - // Scenario interactions use unnumbered path placeholders so that comparison - // against incoming paths (normalized the same way) is always idempotent. + // Normalized so comparison against incoming paths (normalized the same way) is idempotent. normalized.request.path = normalizeUrlPath(params.pathname); const key = fixtureKey(params.method, params.pathname); @@ -594,9 +590,7 @@ function recordFixture(params: { mkdirSync(keyDir, { recursive: true }); const nextIndex = nextFixtureIndex(keyDir); - // Cap: the matcher's `index % entries.length` wrap means more than a few - // entries adds bytes without adding coverage. Stop persisting after the cap - // is reached; the proxied response is still returned to the caller. + // Beyond the cap, the proxied response is still returned but no longer persisted. if (nextIndex <= MAX_FIXTURE_ENTRIES) { const indexStr = nextIndex === 1 ? "default" : String(nextIndex); @@ -610,7 +604,6 @@ function recordFixture(params: { ); } - // If a scenario is active, also append this interaction to interactions.json. if (params.scenario.name !== null) { params.scenario.log.push({ request: normalized.request, response: normalized.response }); writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); @@ -630,10 +623,9 @@ interface DockerProxyResult { bodyPromise: Promise; } -/** Idle timeout: abort if the upstream socket goes silent for this long. The - * previous hard timeout (60s wall-clock) killed legitimate slow operations - * like first-time image pulls. An idle timeout only kills truly stuck - * connections — anything still emitting progress events stays alive. */ +/** Idle timeout: abort if the upstream socket goes silent for this long. Only + * kills truly stuck connections — a slow but still-active operation (e.g. a + * first-time image pull) stays alive as long as it keeps emitting progress. */ const DOCKER_SOCKET_IDLE_TIMEOUT_MS = 60_000; async function proxyToDockerSocket( @@ -956,8 +948,8 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (req.method === "DELETE") { - // In record mode, always flush interactions.json (even when empty) so that - // tests which trigger a global error before any API call still get a scenario file. + // Flush interactions.json even when empty, so a test that hits a global + // error before any API call still gets a scenario file. if (ctx.isRecord && ctx.scenario.name !== null) { writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); } diff --git a/apps/cli-e2e/src/server/request-matcher.ts b/apps/cli-e2e/src/server/request-matcher.ts index f8a12595b8..b77e02e0fd 100644 --- a/apps/cli-e2e/src/server/request-matcher.ts +++ b/apps/cli-e2e/src/server/request-matcher.ts @@ -84,12 +84,11 @@ export function resetCounters(counters: SequenceCounters): void { counters.clear(); } -/** Sort array bodies canonically so body comparison is order-insensitive for - * APIs where element ordering is not semantically meaningful (e.g. bulk-create - * secrets). Go maps iterate in non-deterministic order, so proxy commands that - * build request bodies from maps produce different orderings on Linux vs macOS. - * Nested object fields are still compared exactly — only the top-level array - * order is normalized. */ +/** Sorts array bodies canonically so comparison is order-insensitive where + * element order isn't semantically meaningful (e.g. bulk-create secrets) — a + * proxied command building a request body from a Go map can produce a + * different key order per platform. Nested object fields are still compared + * exactly; only the top-level array order is normalized. */ export function sortBody(body: unknown): unknown { if (Array.isArray(body)) { return [...body].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); diff --git a/apps/cli-e2e/src/tests/auth.e2e.test.ts b/apps/cli-e2e/src/tests/auth.e2e.test.ts index a6b0dd25b8..d2ea7ae761 100644 --- a/apps/cli-e2e/src/tests/auth.e2e.test.ts +++ b/apps/cli-e2e/src/tests/auth.e2e.test.ts @@ -1,28 +1,8 @@ import { describe, it } from "vitest"; -// TODO: Implement auth e2e tests once the TS Legacy Port exposes a way to opt out of -// keychain access (e.g. a SUPABASE_NO_KEYRING env var respected by the Go -// binary). Without it, login/logout spawn macOS Security dialogs ("A keychain -// cannot be found to store 'test'") that block the subprocess indefinitely, -// causing every test to time out. -// -// Required before implementing: -// - The Go CLI must honour SUPABASE_NO_KEYRING (or equivalent) so that -// keyring operations fall back to the filesystem inside the isolated -// SUPABASE_HOME / workspace directory instead of touching the OS keychain. -// -// Planned test coverage (see CLI-1365 for full spec): -// -// login -// - testBehaviour: saves token and prints success with --token -// - testBehaviour: saves token with --token and custom --name -// - testBehaviour: includes debug output with --debug -// - testBehaviour: exits non-zero on invalid token format -// -// logout -// - testBehaviour: exits zero after login with --yes -// - testBehaviour: exits zero gracefully when not logged in with --yes -// (double-logout pattern to clear any stale keychain state between runs) +// TODO(CLI-1365): implement auth e2e tests once the CLI can opt out of keychain +// access (e.g. SUPABASE_NO_KEYRING) — without it, login/logout spawn a macOS +// keychain dialog that blocks the subprocess indefinitely. describe("auth", () => { it.todo("login and logout tests blocked on Go CLI keyring opt-out (see file comment)"); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index c4d7abc20c..617c6158cc 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -5,9 +5,9 @@ import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; /** - * Write a supabase/config.toml covering every section the Go updater touches. - * For each section, it'll create a small diff to the recorded test project. - * Without any diff, no PATCH/POST requests will be sent to the management API. + * Writes a supabase/config.toml covering every section `config push` + * reconciles, each with a small diff against the recorded test project — a + * section without a diff sends no PATCH/POST to the management API. */ function writeConfigToml(dir: string): void { mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -55,10 +55,7 @@ enabled = true ); } -/** - * The CLI will prompt the user y/n for each section that has a diff. - * The test process runs with stdin closed, so run the commands with the `--yes` flag. - */ +// `--yes` skips the per-section y/n prompt, since the test process runs with stdin closed. describe("config push", () => { testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { writeConfigToml(workspace.path); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index 7ce1786ae2..e21e572773 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -4,17 +4,11 @@ import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; -// CLI-1489: v2.99.0 introduced a TypeScript config loader in the Bun shell -// that strictly decoded supabase/config.toml through an Effect schema. Any -// non-string field written as env(VAR) — e.g. a port — was rejected before -// env-resolution could run, crashing the CLI at boot with -// CliConfigParseError. This test runs against the compiled CLI so the -// regression cannot return. +// Regression guard: a non-string field written as `env(VAR)` (e.g. +// a port) must not crash config decoding before env resolution runs. // -// A 401 is injected so the test does not need a real API fixture: pre-fix the -// TS shells crashed before any API call, post-fix they reach the (faked) API -// and get the injected error. Either way we only assert that the CLI got -// past config decode. +// A 401 is injected so the test doesn't need a real API fixture; it only +// asserts that the CLI got past config decode. function writeConfigWithEnvPorts(dir: string): void { mkdirSync(join(dir, "supabase"), { recursive: true }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index dd07f0b650..0adcb9b5ad 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -3,19 +3,11 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; -// --------------------------------------------------------------------------- -// Workspace helpers -// --------------------------------------------------------------------------- - -/** Write .supabase/.temp/project-ref and a stub pooler-url so --linked commands - * can pass ParseDatabaseConfig without a real postgres TCP connection. - * - * The Go CLI's PersistentPreRunE calls ParseDatabaseConfig which, for --linked, - * tries a TCP probe to db.{ref}.localhost:5432. Nothing listens there in the - * test harness. By writing a pooler-url file (which GetPoolerConfig reads), the - * CLI takes the pooler path instead. Combined with SUPABASE_DB_PASSWORD (set in - * harness.ts), ParseDatabaseConfig succeeds without any network call, so the - * command reaches its RunE and makes the Management API call under test. */ +/** Writes .supabase/.temp/project-ref and a stub pooler-url so --linked + * commands resolve a connection without a live postgres TCP probe (nothing + * listens on db.{ref}.localhost:5432 in the test harness); combined with + * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler + * and makes the Management API call under test. */ function linkProject(dir: string, ref: string): void { const tempDir = join(dir, "supabase", ".temp"); mkdirSync(tempDir, { recursive: true }); @@ -36,10 +28,6 @@ function seedMigration(dir: string): void { writeFileSync(join(migrationsDir, "20240101000000_e2e_test.sql"), TEST_MIGRATION_SQL); } -// --------------------------------------------------------------------------- -// db advisors -// --------------------------------------------------------------------------- - describe("db advisors", () => { describe("db advisors:security", () => { testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { @@ -139,10 +127,6 @@ describe("db advisors", () => { }); }); -// --------------------------------------------------------------------------- -// db query -// --------------------------------------------------------------------------- - describe("db query", () => { describe("db query:linked", () => { testBehaviour( @@ -193,10 +177,6 @@ describe("db query", () => { }); }); -// --------------------------------------------------------------------------- -// db push -// --------------------------------------------------------------------------- - describe("db push", () => { describe("db push:dry-run", () => { testBehaviour( @@ -232,10 +212,6 @@ describe("db push", () => { }); }); -// --------------------------------------------------------------------------- -// db pull -// --------------------------------------------------------------------------- - describe("db pull", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "pull", "--local"]); @@ -244,10 +220,6 @@ describe("db pull", () => { }); }); -// --------------------------------------------------------------------------- -// db lint -// --------------------------------------------------------------------------- - describe("db lint", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "lint", "--local"]); @@ -256,10 +228,6 @@ describe("db lint", () => { }); }); -// --------------------------------------------------------------------------- -// db dump -// --------------------------------------------------------------------------- - describe("db dump", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "dump", "--local"]); @@ -274,10 +242,6 @@ describe("db dump", () => { }); }); -// --------------------------------------------------------------------------- -// db reset -// --------------------------------------------------------------------------- - describe("db reset", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "reset", "--local"]); @@ -286,10 +250,6 @@ describe("db reset", () => { }); }); -// --------------------------------------------------------------------------- -// test new -// --------------------------------------------------------------------------- - describe("test new", () => { testBehaviour("creates a pgTAP test file", async ({ run, workspace }) => { const result = await run(["test", "new", "my_test"]); @@ -307,10 +267,6 @@ describe("test new", () => { }); }); -// --------------------------------------------------------------------------- -// test db -// --------------------------------------------------------------------------- - describe("test db", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["test", "db", "--local"]); diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 52fc3d405f..086843f3d8 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -9,10 +9,9 @@ const MODE: CliE2eMode = export const isRecording = MODE === "record"; -// The replay server + tests/setup.ts key recording off the RECORD env var -// directly. Keep RECORD in sync with MODE in BOTH directions so an explicit -// CLI_E2E_MODE wins over a stale RECORD env — e.g. CLI_E2E_MODE=replay must NOT -// record and wipe fixtures just because RECORD=true lingers in the shell. +// The replay server + tests/setup.ts key recording off RECORD directly, so keep +// it in sync with MODE in both directions: an explicit CLI_E2E_MODE=replay must +// not record and wipe fixtures just because RECORD=true lingers in the shell. if (isRecording) { process.env["RECORD"] = "true"; } else { diff --git a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts index 14eadc3330..fc0b9acfe0 100644 --- a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts +++ b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts @@ -3,22 +3,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -// CLI-1970 shrank the bundled `supabase-go` binary down to exactly the -// commands the TypeScript CLI's `GoProxy` can spawn — every other Go -// command was deleted outright. Every TS integration test stubs that -// subprocess boundary, so nothing in the normal test pyramid notices when a -// still-reachable Go command gets trimmed away by mistake: that regression -// was previously caught only by a human audit. +// The bundled `supabase-go` binary retains only the commands the TypeScript +// CLI's `GoProxy` can spawn; every other Go command was deleted. TS +// integration tests stub that subprocess boundary, so nothing else notices if +// a still-reachable Go command gets trimmed away by mistake. // -// This suite is the durable guard: it enumerates every argv shape the TS -// side can hand to `GoProxy` and asserts the built `supabase-go` -// binary still resolves it. If this fails after trimming the Go binary, -// either the TS spawn surface grew (add the new command to the retained +// This suite enumerates every argv shape the TS side can hand to `GoProxy` +// and asserts the built `supabase-go` binary still resolves it. If this +// fails, either the TS spawn surface grew (add the command to the retained // set in `apps/cli-go`) or the trim cut too deep (restore the command). // // `SUPABASE_GO_BINARY` is only set to a freshly built binary in CI (see -// `.github/workflows/test.yml`); locally this whole suite no-ops so a -// developer without a Go toolchain/build isn't forced to fail it. +// `.github/workflows/test.yml`); locally this whole suite no-ops. const GO_BINARY = process.env["SUPABASE_GO_BINARY"]; describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", () => { @@ -30,20 +26,17 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( beforeAll(() => { workspaceDir = mkdtempSync(join(tmpdir(), "cli-e2e-go-binary-surface-")); - // `cmd/root.go`'s `checkUpgrade` hits the real GitHub releases API on - // every invocation that returns a nil error (e.g. every `--help` call - // below), unless a `supabase/.temp/cli-latest` cache file already exists - // relative to the spawn's cwd and is less than 10h old (`shouldFetchRelease`). - // Pre-seeding it here keeps this whole suite hermetic instead of quietly - // depending on network access. + // The upgrade check hits the real GitHub releases API on every invocation + // that returns a nil error (e.g. every `--help` call below), unless a + // `supabase/.temp/cli-latest` cache file already exists and is less than + // 10h old. Pre-seeding it here keeps this suite hermetic. mkdirSync(join(workspaceDir, "supabase", ".temp"), { recursive: true }); writeFileSync(join(workspaceDir, "supabase", ".temp", "cli-latest"), "v0.0.0"); // A bogus `--profile` for the two Management-API-gated delegates (`gen - // keys`, `functions download --legacy-bundle`). The unique profile name - // guarantees an OS-keyring credential from a real `supabase login` can - // never match it, and the unreachable api_url means even a stray token - // match still fails at connect instead of reaching a real API. + // keys`, `functions download --legacy-bundle`): the unique profile name + // can never match a real stored credential, and the unreachable api_url + // means even a stray match still fails at connect. bogusProfilePath = join(workspaceDir, "profile.yaml"); writeFileSync( bogusProfilePath, @@ -107,27 +100,19 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( const output = stdout + stderr; expect(exitCode).toBe(0); expect(output).not.toContain("unknown command"); - // A deleted NESTED subcommand (as opposed to a deleted top-level - // one) does not produce an "unknown command" error at all: cobra's - // default Args validator only rejects unmatched positional args on - // the root command, so `db diff --help` with `diff` deleted would - // otherwise silently fall through to printing `db`'s own help and - // exit 0 — verified empirically by deleting a command and watching - // this exact assertion catch it. Asserting the full resolved - // command path appears in the printed usage closes that gap: cobra - // renders `Usage:\n supabase [flags]` using each ancestor's - // Name() (the Use string's first token), so this substring is only - // present when the whole path actually resolved. + // A deleted nested subcommand doesn't error "unknown command": cobra + // only rejects unmatched args on the root command, so `db diff --help` + // with `diff` deleted would fall through to `db`'s own help (exit 0). + // Asserting the full command path appears in the usage output closes + // that gap — cobra only prints it once the whole path has resolved. expect(output).toContain(`supabase ${path.join(" ")}`); }, 5_000); } }); describe("accepts the exact argv shape the TS proxy builds", () => { - // `db diff --use-pg-schema` (diff.handler.ts's `rebuildPgSchemaDelegateArgs`). - // `db diff` always provisions a Docker shadow database first, regardless - // of the differ engine, so the bogus DOCKER_HOST is what makes this fail - // fast rather than the bogus --db-url. + // `db diff` always provisions a Docker shadow database first, so the bogus + // DOCKER_HOST is what makes this fail fast, not the bogus --db-url. test("db diff --use-pg-schema", () => { const { exitCode, stderr } = runGo([ "db", @@ -142,10 +127,8 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `db remote changes` (changes.handler.ts), with the complete global-flag - // set root.ts can prepend (globalArgs) — the only invocation in this - // suite exercising all ten at once. Also provisions a Docker shadow first - // (same as `db diff`), so the bogus DOCKER_HOST is what trips this one. + // The only invocation in this suite exercising the full global-flag set at + // once. Also provisions a Docker shadow first, so DOCKER_HOST trips it. test("db remote changes (full global flag set)", () => { const { exitCode, stderr } = runGo([ "--output", @@ -176,10 +159,8 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `gen keys` (keys.handler.ts). Gated behind Go's Management-API login - // check before any network call, so an isolated SUPABASE_HOME (no stored - // credentials) plus the bogus --profile fails fast without ever reaching - // a real API — regardless of whatever is logged into on this machine. + // Gated behind a login check before any network call, so an isolated + // SUPABASE_HOME plus the bogus --profile fails fast without reaching a real API. test("gen keys", () => { const { exitCode, stderr } = runGo([ "gen", @@ -196,8 +177,7 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `functions download --legacy-bundle` (shared/functions/download.ts's - // `makeGoProxyLegacyBundleArgs`). Same login-gate fail-fast as `gen keys`. + // Same login-gate fail-fast as `gen keys`. test("functions download --legacy-bundle", () => { const { exitCode, stderr } = runGo([ "functions", @@ -215,14 +195,10 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( }); describe("negative control: a deleted command still reports unknown", () => { - // `["db", "start"]` deliberately is NOT used here even though it's a - // deleted command: cobra's default Args validator only rejects unmatched - // positional args on the ROOT command. A nested group like `db` silently - // falls through to printing its own help (exit 0) for an unrecognized - // child instead of erroring — verified empirically against the built - // binary. Only a deleted TOP-LEVEL command reliably reproduces the - // "unknown command" failure this guard needs to detect drift in both - // directions. + // `["db", "start"]` isn't used here even though it's a deleted command: + // like the nested-subcommand case above, `db` would fall through to its + // own help (exit 0) instead of erroring. Only a deleted top-level command + // reliably reproduces "unknown command". for (const deletedCommand of ["inspect", "start"]) { test(`supabase-go ${deletedCommand} reports unknown command`, () => { const { exitCode, stderr } = runGo([deletedCommand]); diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index 7c59917e3e..9f5014dab1 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -3,10 +3,6 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function setupInspectWorkspace(dir: string, pgPort: number): void { mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync( @@ -24,10 +20,6 @@ async function setPgFixture(apiUrl: string, key: string): Promise { if (!res.ok) throw new Error(`Failed to set PG fixture "${key}": ${await res.text()}`); } -// --------------------------------------------------------------------------- -// Subcommand table -// --------------------------------------------------------------------------- - const SUBCOMMANDS = [ { name: "db-stats", fixtureKey: "db-stats", assertValue: "42 MB" }, { name: "replication-slots", fixtureKey: "replication-slots", assertValue: "test-slot" }, @@ -44,10 +36,6 @@ const SUBCOMMANDS = [ { name: "traffic-profile", fixtureKey: "traffic-profile", assertValue: "sessions" }, ] as const; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("inspect:flags", () => { testBehaviour("rejects --db-url with --local", async ({ run, workspace, pgMockPort }) => { setupInspectWorkspace(workspace.path, pgMockPort); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index c92dbe0058..3a0c349e42 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -18,8 +18,7 @@ describe("migrations", () => { testBehaviour("exits non-zero without name argument", async ({ run }) => { const result = await run(["migration", "new"]); expect(result.exitCode).not.toBe(0); - // CLI-1901: a missing positional argument's usage block now prints to - // stderr (never stdout) instead of being duplicated across both. + // A missing positional argument's usage block prints to stderr only. expect(result.stdout).toBe(""); expect(result.stderr).toContain("migration name"); }); @@ -59,10 +58,8 @@ 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); - // 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. + // A missing required flag's error prints once, without a duplicate + // usage dump, and spells the flag name without its `--` prefix. expect(result.stderr).toContain('"status" not set'); }); diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index ec04a5dc76..d4818f851c 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -61,10 +61,10 @@ describe("init", () => { }); describe("link", () => { - // Not using testBehaviour here because the testBehaviour `run` fixture always - // injects SUPABASE_PROJECT_ID, which the new Go CLI accepts as a substitute for - // --project-ref in non-TTY mode, bypassing the required-flag check. A raw test - // lets us omit projectId so the CLI correctly requires the --project-ref flag. + // Not using testBehaviour here because its `run` fixture always injects + // SUPABASE_PROJECT_ID, which the CLI accepts as a substitute for + // --project-ref in non-TTY mode. A raw test omits it so the CLI correctly + // requires the --project-ref flag. test("exits non-zero without --project-ref in non-TTY", async () => { const serverUrl = inject("replayServerUrl") as string; const dir = makeTempDir("cli-e2e-link-no-ref-"); @@ -80,8 +80,7 @@ describe("link", () => { }); // The testBehaviour run fixture always injects SUPABASE_PROJECT_ID, which the - // new Go CLI accepts in place of --project-ref, bypassing the required-flag - // check. Link therefore proceeds to the API and succeeds. + // CLI accepts in place of --project-ref, so link proceeds to the API and succeeds. testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", async ({ run }) => { const result = await run(["link"]); expect(result.exitCode).toBe(0); @@ -120,9 +119,8 @@ describe("link", () => { expect(result.stderr).toContain("Internal Server Error"); }); - // link makes concurrent Management API calls after the initial project-status and - // api-keys calls. The concurrent service calls fail silently (non-fatal). Only - // the first two sequential calls need fixture entries. + // link's concurrent service calls (after project-status and api-keys) fail + // silently, so only the first two sequential calls need fixture entries. testBehaviour.skipIf(isRecording)( "links project successfully", async ({ run, projectRef, workspace }) => { @@ -151,9 +149,7 @@ describe("unlink", () => { expect(result.stderr).toContain("supabase link"); }); - // The success path (pre-populate project-ref → unlink succeeds) is omitted: the - // unlink handler deletes the database-password keyring entry on success. On - // Linux CI (no D-Bus session bus) the keyring call returns an unhandled error - // and the command exits 1. The not-linked error path above gives meaningful - // coverage; deeper success-path behaviour is covered by unlink.integration.test.ts. + // The success path is omitted: unlink deletes a keyring entry on success, and + // Linux CI (no D-Bus session bus) makes that call exit 1. See + // unlink.integration.test.ts for success-path coverage. }); diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index cd99b86827..8993de60eb 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -40,12 +40,7 @@ const testStack = testBehaviour.extend({ }, }); -// --------------------------------------------------------------------------- -// services -// --------------------------------------------------------------------------- -// `services` prints a baked-in Go-parity service matrix, so DOCKER_HOST is not -// needed. - +// `services` prints a static service matrix, so DOCKER_HOST is not needed. describe("services", () => { testBehaviour("lists known service images", async ({ run }) => { const result = await run(["services"]); @@ -57,14 +52,8 @@ describe("services", () => { }); }); -// --------------------------------------------------------------------------- -// status -// --------------------------------------------------------------------------- - -// CLI-2167: `status` resolves and prints the current linked -// project/branch on stdout, before any Docker/daemon work runs, in every -// output mode — an adjudicated, deliberate TS-only extension with no Go -// counterpart (Go's `status` never had a link-state concept). Go's stdout for +// `status` resolves and prints the current linked project/branch on stdout +// before any Docker/daemon work runs, in every output mode. describe("status", () => { testStack("exits 1 when stack is not running", async ({ workspace, stackRun }) => { setupStackWorkspace(workspace.path); @@ -74,10 +63,6 @@ describe("status", () => { }); }); -// --------------------------------------------------------------------------- -// stop -// --------------------------------------------------------------------------- - describe("stop", () => { testStack("succeeds when stack is not running", async ({ workspace, stackRun }) => { setupStackWorkspace(workspace.path); @@ -85,8 +70,7 @@ describe("stop", () => { expect(result.exitCode).toBe(0); }); - // cobra's MarkFlagsMutuallyExclusive validates this before the command runs — - // no Docker or API calls are made. + // Flag validation happens before the command runs, so no Docker or API calls are made. testStack( "exits 1 with mutual-exclusion error for --project-id and --all", async ({ workspace, stackRun }) => { @@ -98,10 +82,6 @@ describe("stop", () => { ); }); -// --------------------------------------------------------------------------- -// start -// --------------------------------------------------------------------------- - describe("start", () => { testStack( "exits 1 with Docker error when Docker is unavailable", @@ -115,21 +95,14 @@ describe("start", () => { }, ); - // start → status → status --override-name → stop lifecycle test. - // These must run in sequence in a single shared workspace so that status - // and stop see the stack that start brought up. - // TODO: record these in an environment where the full Supabase Docker stack starts - // cleanly through the TCP relay proxy (vector health check fails on this machine). + // Must run in sequence in a shared workspace so status/stop see the stack start brought up. + // TODO: record once the full stack starts cleanly through the relay proxy (vector health check fails here). test.todo("start → status → stop lifecycle"); test.todo("starts with --exclude studio and stops cleanly"); }); -// --------------------------------------------------------------------------- -// seed buckets -// --------------------------------------------------------------------------- // `seed buckets` makes storage HTTP calls (not Docker), so plain testBehaviour // with `run` is correct. - describe("seed buckets", () => { testBehaviour("creates buckets defined in config", async ({ workspace, run, apiUrl }) => { mkdirSync(join(workspace.path, "supabase"), { recursive: true }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index 257009d57b..b0975ba22d 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -39,10 +39,6 @@ async function clearOverrides(apiUrl: string): Promise { await fetch(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" }); } -// --------------------------------------------------------------------------- -// storage ls -// --------------------------------------------------------------------------- - describe("storage ls", () => { testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -106,10 +102,6 @@ describe("storage ls", () => { }); }); -// --------------------------------------------------------------------------- -// storage cp -// --------------------------------------------------------------------------- - describe("storage cp", () => { testBehaviour("uploads local file to storage", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -254,10 +246,6 @@ describe("storage cp", () => { }); }); -// --------------------------------------------------------------------------- -// storage mv -// --------------------------------------------------------------------------- - describe("storage mv", () => { testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -344,10 +332,6 @@ describe("storage mv", () => { }); }); -// --------------------------------------------------------------------------- -// storage rm -// --------------------------------------------------------------------------- - describe("storage rm", () => { testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 679a0609dd..cbb466baff 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -40,7 +40,9 @@ What this means in practice: definitions must keep matching the Go binary they forward to). - **Do not write new comments, doc sections, or helper names framed as "Go parity" or "matches Go".** Describe behavior in its own terms. When touching code that carries old Go-parity - framing, clean it up opportunistically. + framing, clean it up opportunistically. The repo-wide comment policy is the `## Comments` + section of the root `AGENTS.md`; Go `file.go:NN` citations and "verified against Go" notes + are provenance and never belong in a comment. - For history: Go source for ported commands was deleted in CLI-1970; the last commit with it intact is `7b469f5b3` (`internal/start` was deleted separately in CLI-1966; its pin is `a253ccba2`). [ADR 0016](../../docs/adr/0016-port-completion-and-go-cli-authority-scope.md) diff --git a/apps/cli/scripts/backfill-release-notes.ts b/apps/cli/scripts/backfill-release-notes.ts index eff5b98139..eaa326a975 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -1,17 +1,15 @@ #!/usr/bin/env bun -// Re-derive a GitHub Release's changelog from its tag's commit using the -// *current* semantic-release config, regardless of what apps/cli/package.json +// Re-derives a GitHub Release's changelog from its tag's commit using the +// current semantic-release config, regardless of what apps/cli/package.json // looked like when the tag was cut. Used both as a local debugging tool and // as the engine behind .github/workflows/backfill-release-notes.yml. // -// Why every step matters: when backfilling an old tag, semantic-release -// trips on several things at once - it picks the wrong branch from CI env -// vars, can't read channel notes for historical tags, refuses to proceed if -// the local branch is "behind" the real remote, and uses whatever -// release.branches/plugins config existed at the tag's commit (which on -// this repo pre-dates the `channel: "beta"` fix from commit 2515885 and -// the release-notes-generator plugin from #5316). The script works around -// each of those in a temp clone so the original workspace stays clean. +// Backfilling an old tag trips semantic-release on several fronts at once: it +// picks the wrong branch from CI env vars, can't read channel notes for +// historical tags, refuses to proceed when the local branch is "behind" the +// real remote, and uses whatever release config existed at the tag's commit. +// The script works around each of those in a temp clone so the original +// workspace stays clean. // // Usage: // bun apps/cli/scripts/backfill-release-notes.ts --tag v2.99.0-beta.1 @@ -107,14 +105,11 @@ try { await $`git clone --quiet --no-local ${repoRoot} ${clone}`; // `git notes add` (used below to seed channel notes) requires a committer - // identity. CI runners don't ship one in ~/.gitconfig, so without this the - // seeding loop silently fails - semantic-release then can't see prior beta - // tags on the beta channel and computes the wrong next version. + // identity, which CI runners don't ship in ~/.gitconfig. await $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`; await $`git -C ${clone} config --local user.name backfill-release-notes`; - // Same reason - and `commit.gpgsign`/`tag.gpgsign` inherited from a user's - // global config would make `git notes add` fail in environments without a - // signing key. The temp clone never publishes anything, so disable signing. + // A signing key inherited from a user's global config would also make + // `git notes add` fail; the temp clone never publishes anything, so disable it. await $`git -C ${clone} config --local commit.gpgsign false`; await $`git -C ${clone} config --local tag.gpgsign false`; @@ -134,12 +129,10 @@ try { .quiet(); const sha = (await $`git -C ${clone} rev-list -n 1 ${tag}`.text()).trim(); - // Delete the target tag *and* any other local tags pointing at the same - // commit. When a stable and a beta share a commit (e.g. v2.100.0 and - // v2.100.0-beta.2 both at 9a22aff6), semantic-release picks the higher- - // semver one as lastRelease - which becomes HEAD itself, leaving 0 - // commits and "no release". Dropping the co-incident tags lets it fall - // back to the genuine prior release on the channel. + // Delete the target tag and any other local tag pointing at the same commit: + // when a stable and a beta share a commit, semantic-release picks the + // higher-semver one as lastRelease (which becomes HEAD itself, leaving "no + // release"). Dropping the co-incident tags falls back to the genuine prior release. const coincidentTagsOut = await $`git -C ${clone} tag --points-at ${sha}`.text(); const coincidentTags = coincidentTagsOut .split("\n") @@ -163,12 +156,11 @@ try { } } - // semantic-release's `git log --notes=refs/notes/semantic-release*` reader - // returns channels=[null] for any tag missing an annotation. With the - // current prerelease filter that drops the tag entirely, so the lastRelease - // walks past unannotated tags and ends up far enough back to drag - // unrelated commits into the changelog. Seed a channel note for every - // reachable tag that lacks one; convention is taken from the tag name. + // semantic-release's notes reader returns channels=[null] for any tag + // missing an annotation, and the prerelease filter drops those tags + // entirely — walking lastRelease back far enough to drag unrelated commits + // into the changelog. Seed a channel note for every reachable tag that + // lacks one, inferring the channel from the tag name. const mergedTagsOut = await $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(); const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); for (const prevTag of mergedTags) { @@ -185,36 +177,27 @@ try { await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(); } - // Apply the *current* release config to the historical checkout. Before - // commit 2515885 (May 11) the develop branch had no explicit `channel`, - // which silently broke prerelease tag matching; before #5316 the plugin - // chain didn't include release-notes-generator. Using the current config - // gives the right notes shape regardless of what shipped at the tag. + // Apply the current release config to the historical checkout, so the notes + // come out in the right shape regardless of what release config shipped at the tag. const clonePkgPath = path.join(clone, "apps/cli/package.json"); const clonePkg = JSON.parse(await readFile(clonePkgPath, "utf8")); clonePkg.release = rootPkg.release; await writeFile(clonePkgPath, `${JSON.stringify(clonePkg, null, 2)}\n`); await copyLocalReleasePlugins(clone); - // semantic-release runs `git ls-remote ` and - // silently exits with "behind remote" when the remote tip differs from - // HEAD - which it always does when backfilling an old tag. Use git's - // insteadOf to redirect the real GitHub URL to the local clone for the - // duration of this run; semantic-release still treats repositoryUrl as - // the GitHub URL so commit/PR links in the rendered notes are correct. + // semantic-release exits with "behind remote" whenever the remote tip + // differs from HEAD, which it always does when backfilling an old tag. Use + // git's insteadOf to redirect the GitHub URL to the local clone for this + // run, while semantic-release still renders commit/PR links against the real URL. await $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`; console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); console.error(`==> Running semantic-release --dry-run`); - // semantic-release uses env-ci to detect the current branch, which reads - // GITHUB_REF (and friends) from the GitHub Actions environment. `noCi: true` - // only bypasses the "not in CI" guard - it does not stop env-ci from - // resolving the branch from CI vars. When backfilling v2.100.1 from a - // workflow that ran on develop, env-ci returns "develop" even though the - // clone's HEAD points at main, and semantic-release then complains that - // local develop is behind remote. Strip the GitHub Actions detection vars - // so env-ci falls back to reading the branch from git HEAD in the clone. + // env-ci detects the branch from GITHUB_REF (and friends) even with `noCi: + // true`, which only bypasses the "not in CI" guard. That can name a branch + // other than the one the clone's HEAD actually points at, so strip the + // GitHub Actions detection vars and let env-ci read HEAD directly. const childEnv = { ...process.env }; for (const key of [ "GITHUB_ACTIONS", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index a7ce9cb8e8..e9326ed8ce 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -3,11 +3,9 @@ import { $ } from "bun"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; /** - * Compile the CLI shell to a standalone binary, embedding the pre-bundled - * edge-runtime template via the `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define so - * the binary serves Functions offline without bundling at runtime - * (supabase/supabase#45570). Used by the `build:binary` script; the multi-target - * release build in `build.ts` injects the same define. + * Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled + * edge-runtime template through `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` so Functions serve + * offline without bundling at runtime (supabase/supabase#45570). */ const entrypoint = "src/main.ts"; const outfile = "dist/supabase"; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 73c262ea8f..8fa9fa2df9 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -181,13 +181,9 @@ async function buildGoTarget(target: (typeof TARGETS)[number]) { } /** - * Decide how to sign the macOS binaries. - * - * `rcodesign` (the apple-codesign project) signs Mach-O binaries from Linux, - * so signing happens inline on the existing build runner. Release CI installs - * it and sets SUPABASE_CLI_REQUIRE_SIGNING=1 so a missing tool fails the build; - * local builds without rcodesign degrade to "off" with a warning so - * contributors can still produce (unsigned) binaries. + * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing + * runs inline on this build runner; falls back to "off" with a warning unless + * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. */ function resolveSignMode(): SignMode { if (Bun.which("rcodesign")) { @@ -224,15 +220,12 @@ async function signDarwinBinaries(mode: SignMode) { const identifier = MACOS_IDENTIFIERS[binary]; console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); - // No key material => rcodesign emits a complete ad-hoc signature - // (CodeDirectory + RequirementSet + empty CMS), equivalent to - // `codesign --sign -`, replacing Bun/Go's linker-signed signature. + // No key material, so rcodesign produces an ad-hoc signature, equivalent to + // `codesign --sign -`, replacing Bun/Go's linker-signed one. await $`rcodesign sign --binary-identifier ${identifier} ${binPath}`; - // Linux-side verification that runs on every build: the signature must - // carry exactly our identifier (matched on the whole value, so the SFE's - // `com.supabase.cli` can't satisfy the sidecar's `com.supabase.cli-go`) - // and must no longer be linker-signed. + // Matches the identifier's whole value, so the SFE's `com.supabase.cli` can't satisfy + // the sidecar's `com.supabase.cli-go`, and confirms the signature is no longer linker-signed. const info = await $`rcodesign print-signature-info ${binPath}`.text(); const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; if (signedIdentifier !== identifier) { @@ -260,9 +253,8 @@ async function archiveTarget(target: (typeof TARGETS)[number]) { ]; await $`zip -j ${archivePath} ${files}`; - // setup-cli and other download clients always fetch a .tar.gz, including on - // Windows where tc.extractTar handles the archive. Publish a matching - // tar.gz alongside the .zip so those clients keep working. See #5257. + // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so + // publish one alongside the .zip. See #5257. const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); const tarArchivePath = path.join(distDir, tarArchive); const tarFiles = [`supabase${target.ext}`, `supabase-go${target.ext}`]; @@ -296,9 +288,8 @@ async function buildMuslBinaries() { `--outfile=${outfile}`, ]); - // Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on - // musl too. Copy it from the matching glibc package so the published musl npm - // package contains the supabase-go binary that GoProxy resolves to. + // The Go binary is fully static (CGO_ENABLED=0), so the glibc build works on musl too; + // copy it into the musl package so GoProxy finds supabase-go there. const glibcTarget = TARGETS.find( (candidate) => "nfpmArch" in candidate && candidate.nfpmArch === target.nfpmArch, ); @@ -329,9 +320,8 @@ async function buildLinuxPackages(version: string) { const outPath = path.join(distDir, outFile); const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; - // Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on - // musl too. For apk (musl), binDir is muslBinDir for the TS binary but we still - // reference supabase-go from the glibc dir where it was built. + // The Go binary is fully static, so apk (musl) still references supabase-go + // from the glibc dir where it was built. const contents: Array<{ src: string; dst: string }> = [ { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, { src: path.join(glibcBinDir, "supabase-go"), dst: "/usr/bin/supabase-go" }, @@ -408,9 +398,8 @@ await Promise.all(TARGETS.map(buildTarget)); console.log("\nCompiling Go CLI for all targets..."); await Promise.all(TARGETS.map(buildGoTarget)); -// Sign macOS binaries before archiving so every channel (npm platform -// packages, Homebrew, GitHub Release archives, checksums) ships the signed -// bytes. Must run before archiveTarget / buildLinuxPackages / generateChecksums. +// Must run before archiveTarget / buildLinuxPackages / generateChecksums so every +// distribution channel ships the signed bytes. const signMode = resolveSignMode(); console.log(`\nSigning macOS binaries (mode: ${signMode})...`); await signDarwinBinaries(signMode); diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index 44136f8caa..a172b2b76e 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,30 +1,22 @@ -// Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are -// not yet present on every mirror registry and emits the missing ones as JSON. -// Used by the mirror-template-images workflow to drive the backfill matrix. -// -// It checks every image and skips the ones already mirrored everywhere, so -// re-running after a successful mirror is a no-op. The exported helpers are -// unit-tested in detect-unmirrored-images.unit.test.ts; the entry block below -// (guarded by import.meta.main) performs the only side effects. +// Detects images pinned in apps/cli-go/pkg/config/templates/Dockerfile that are missing from +// any mirror registry and emits them as JSON, for the mirror-template-images workflow's backfill +// matrix. import { spawnSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import process from "node:process"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; /** - * Registries the mirror publishes to and the CLI pulls from, mirroring Go's - * `utils.GetRegistryImageUrls` (`defaultRegistry` + `ghcrRegistry`). An image - * counts as mirrored only when it exists on EVERY one of these — the mirror - * pushes to all of them at once, so a tag present on one but not another is a - * partial mirror that must be re-pushed. + * Registries the mirror publishes to and the CLI pulls from. An image counts as mirrored only + * when it exists on every one of these — a tag present on one but not another is a partial + * mirror that must be re-pushed. */ export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const; /** - * Mirror destination for an upstream image on a single registry, mirroring Go's - * `utils.GetRegistryImageUrl` (`registry + "/supabase/" + basename`). The - * upstream org is dropped — every image is mirrored under the `supabase/` - * namespace — e.g. `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. + * Mirror destination for an upstream image on a single registry. The upstream org is dropped — + * every image is mirrored under the `supabase/` namespace, e.g. + * `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. */ export function mirrorImageTarget(image: string, registry: string): string { const basename = image.slice(image.lastIndexOf("/") + 1); @@ -47,12 +39,9 @@ export interface MirrorPartition { } /** - * Split images by whether they are fully mirrored — present on EVERY registry in - * `registries`. An image missing from any one registry lands in `missing` so the - * backfill re-pushes it everywhere. Every (image, registry) pair is queried, each - * distinct image once. No image is skipped up front — a `supabase/*` image that is - * somehow absent is reported just like a third-party one. Idempotent: once an - * image is on all registries, a re-run skips it. + * Splits images by whether they exist on every registry in `registries`. An image missing from + * any one registry lands in `missing`, since the backfill must re-push it everywhere; images + * already mirrored everywhere land in `mirrored` and are skipped by a re-run. */ export async function partitionUnmirroredImages( images: Iterable, @@ -75,8 +64,6 @@ export async function partitionUnmirroredImages( }; } -// An image counts as mirrored only when this returns true for every registry -// target; both are queried per image by the partition above. function imageExistsOnMirror(target: string): Promise { const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { stdio: "ignore", diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts index f51420cf04..c4cad9e2c2 100644 --- a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -8,8 +8,6 @@ import { describe("detect unmirrored images", () => { test("mirrors an upstream image under the supabase namespace of a registry", () => { - // Third-party orgs are dropped; only the basename is kept, matching Go's - // utils.GetRegistryImageUrl. expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe( "ghcr.io/supabase/postgrest:v14.14", ); @@ -48,7 +46,6 @@ describe("detect unmirrored images", () => { expect(mirrored).toEqual(["library/kong:2.8.1"]); expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - // Two unique images x two registries = four checks. expect(queried).toHaveLength(4); }); diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 319766b5d7..54ec3e8112 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -1,15 +1,11 @@ /** - * Emits the `clispec 001` CLI reference document for the supabase.com docs - * site (`supabase/supabase` `apps/docs/spec/cli_v1_commands.yaml`) from the - * Effect command tree plus the content under `apps/cli/docs/` - * (`supabase/` description overlays, `templates/examples.yaml`). + * Emits the `clispec 001` CLI reference document for the supabase.com docs site from the Effect + * command tree plus the content under `apps/cli/docs/`. Run as + * `bun scripts/generate-docs-spec.ts [version] > cli_v1_commands.yaml`; only the spec YAML goes + * to stdout, diagnostics go to stderr. * - * Contract (documented in `docs/README.md`): the spec YAML is the ONLY thing - * written to stdout, so `bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml` - * yields a clean parseable file; diagnostics go to stderr. The spec version - * defaults to `latest` and can be overridden with an argument (a leading - * `v` is stripped) — the workspace package.json version is a semantic-release - * placeholder, so it is never used. + * The version defaults to `latest` (a leading `v` is stripped); the workspace `package.json` + * version is a semantic-release placeholder and is never used here. */ import path from "node:path"; import process from "node:process"; diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index eff52b6772..74e70b3cb1 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -74,13 +74,10 @@ function generateCommandDocs() { } /** - * Copies `@supabase/config`'s already post-processed (metadata + number- - * union-collapsed) `dist/*.json` schema artifact straight to its docs-site - * public path, rather than re-rendering `toCliConfigJsonSchema()`/ - * `toProjectConfigJsonSchema()` here (CLI-2234) — re-rendering would bypass - * `json-schema-postprocess.ts` and produce a document whose `$id` doesn't - * match what actually gets published. Requires `@supabase/config#build` to - * have already run (wired via `turbo.json`). + * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site + * public path rather than re-rendering it here, since re-rendering would bypass + * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires + * `@supabase/config#build` to have already run (wired via `turbo.json`). */ function copyConfigSchemaAsset(schemaUrl: string, distFileName: string) { const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); diff --git a/apps/cli/scripts/macos-signing.ts b/apps/cli/scripts/macos-signing.ts index a9279eae92..13b3037a63 100644 --- a/apps/cli/scripts/macos-signing.ts +++ b/apps/cli/scripts/macos-signing.ts @@ -1,16 +1,8 @@ /** - * macOS code-signing identifiers per shipped binary. Single source of truth - * shared by the signer (`build.ts`) and the verifier (the macOS smoke-test - * helper) so the identifier a binary is signed with can't drift from the one it - * is verified against. - * - * Why signing exists at all: `bun build --compile` and the Go linker emit a - * degenerate "linker-signed" ad-hoc signature (identifier `a.out`, no - * requirements blob) that macOS 26+ AMFI rejects, SIGKILLing the process at - * launch (CLI-1621 / GitHub #5556). Re-signing with a full ad-hoc signature — - * a complete CodeDirectory + RequirementSet + (empty) CMS, the same shape - * `codesign --sign -` produces — fixes it without any Apple credentials. Phase - * 2 will add Developer ID signing + notarization on top. + * macOS code-signing identifiers, shared by the signer (`build.ts`) and its verifier so both + * agree. `bun build --compile` and the Go linker emit an ad-hoc signature that macOS 26+ AMFI + * SIGKILLs at launch (GitHub #5556); re-signing with a full ad-hoc signature fixes it without + * Apple credentials. */ export type MacBinaryName = "supabase" | "supabase-go"; @@ -20,18 +12,14 @@ export const MACOS_IDENTIFIERS: Record = { }; /** - * Look up the expected identifier for a binary basename. Returns `undefined` - * for anything that isn't a signed macOS binary, so callers verifying an - * arbitrary file path can fail closed. + * Looks up the expected identifier for a binary basename, or `undefined` if it isn't a signed + * macOS binary, so callers verifying an arbitrary path fail closed. */ export function macIdentifierFor(binary: string): string | undefined { return binary === "supabase" || binary === "supabase-go" ? MACOS_IDENTIFIERS[binary] : undefined; } -/** - * The macOS binaries shipped for the CLI: the CLI's Bun SFE alongside - * the Go sidecar it proxies a residual command surface to. - */ +/** The macOS binaries shipped for the CLI: the Bun binary and its Go sidecar. */ export function darwinBinaries(): MacBinaryName[] { return ["supabase", "supabase-go"]; } diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index 37abf57fbd..53a67338a0 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -1,42 +1,11 @@ #!/usr/bin/env bun -// Generate a user-centric GitHub Release body for a Supabase CLI tag -// by running the Claude Agent SDK against tools/release/release-notes-prompt.md -// with the raw semantic-release block substituted in. +// Generates a user-centric GitHub Release body for a Supabase CLI tag by running the Claude Agent +// SDK against tools/release/release-notes-prompt.md, then opens a PR proposing the notes for +// approval (unless --dry-run or --render-only). See the PR body template below for the approval +// workflow. // -// Pipeline shape: -// 1. `backfill-release-notes.ts --tag ` produces the raw semantic-release -// markdown (without writing anything to the GH release). We always -// re-derive this so the proposer is decoupled from whatever happens to -// sit in the release body at the moment. -// 2. The raw block is inlined into tools/release/release-notes-prompt.md in -// place of the {{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} placeholder. -// 3. The Claude Agent SDK runs the rendered prompt with WebFetch + Bash so -// it can investigate PR bodies, linked issues, and changed files (the -// prompt's investigation step is real work, not boilerplate). -// 4. The agent's final assistant message is written to -// release-notes/v.md. -// 5. Unless --dry-run is passed, the script commits the file on a branch -// `release-notes/v` and opens a PR. Approving the PR (as a -// supabase/cli team member) triggers apply-release-notes.yml, which -// pushes the file's contents to the GH release body and closes the PR -// without merging. The PR targets `develop` (not `main`) so an -// accidental merge can never rewrite `main`'s history; in practice the -// file never lands on any branch. -// -// Usage: -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --apply -// -// --tag Required. Release tag (e.g. v2.101.0 or v2.99.0-beta.1). -// --dry-run Print the proposed notes to stdout. Does not write any files, -// does not touch git. -// --apply Write release-notes/v.md, commit on a branch, push, -// and open a PR. Default behavior when neither flag is passed -// is `--dry-run`. -// --render-only Print the rendered prompt (template + raw notes block) -// and exit before any LLM call. Useful for prompt iteration -// and for verifying the pipeline shape without spending tokens. -// --model Optional. Override the Claude model (default: claude-haiku-4-5-20251001). +// Usage: bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 [--dry-run] [--apply] +// [--render-only] [--model ] import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { $ } from "bun"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -93,13 +62,11 @@ if (values["render-only"]) { console.error(`==> Running Claude Agent SDK (model=${values.model})`); const options: Options = { model: values.model, - // The agent needs WebFetch / WebSearch to investigate PR bodies and linked - // issues per the prompt's step 3, and Bash so it can use `gh` for - // authenticated GitHub queries instead of HTML scraping. Edit/Write are - // intentionally excluded — the script owns the final file output. + // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the + // script owns the final file output. allowedTools: ["WebFetch", "WebSearch", "Bash"], - // Don't load the repo's CLAUDE.md or settings.json — the prompt is - // self-contained and we don't want unrelated agent context bleeding in. + // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. settingSources: [], cwd: repoRoot, effort: "low", @@ -126,7 +93,6 @@ if (!finalText.trim()) { process.exit(1); } -// Append the raw notes to the final text to ensure the output is complete. const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); @@ -147,15 +113,9 @@ await writeFile(notesPath, normalized); console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); const branch = `release-notes/v${version}`; -// Always cut the notes branch from origin/develop — the PR base. The workflow -// can be dispatched from an arbitrary feature branch that has diverged from -// the base by many commits; branching off the checked-out ref would drag -// every one of those commits into the PR (so the PR shows N changed files -// instead of just the proposed notes). The notes file is untracked at this -// point, so resetting HEAD to origin/develop leaves it untouched in the -// working tree. We target `develop` rather than `main` so that an accidental -// merge of this approval-only PR lands on the integration branch instead of -// rewriting `main`'s history. +// Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged +// doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. +// Targets develop rather than main so an accidental merge can't rewrite main's history. await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); await $`git add ${notesPath}`.cwd(repoRoot); @@ -179,10 +139,8 @@ if (!pushed) { process.exit(1); } -// Idempotently ensure the `do not merge` label exists on the repo, then attach -// it on PR creation. The label is a visual reminder for reviewers — the -// approval-based apply workflow never invokes the merge button — but the -// publish flow itself does not depend on it. +// Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the +// approval workflow never actually merges. const labelName = "do not merge"; await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` .cwd(repoRoot) diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index b4b969500f..310f2be9ce 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -1,19 +1,11 @@ /** - * Publishes the generated CLI reference to the docs site by opening a PR - * against supabase/supabase, replacing the Go `tools/bumpdoc` that was deleted - * in the monorepo merge (71b543255). The reference has not been republished - * since, so the published spec is frozen at 2.98.2. - * - * Reads the spec on stdin so it composes with the generator exactly the way the - * Go release job did: + * Publishes the generated CLI reference to the docs site by opening a PR against + * supabase/supabase. Reads the spec on stdin: * * bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version * - * Like `bumpdoc`, this is a no-op when the spec is already published and no PR - * is missing: it prints "already up to date" and exits 0. A branch whose spec - * is ahead of base still gets its PR ensured, so a run that pushed but failed - * to open the PR is repaired by the next release. Any real failure exits - * non-zero. + * No-op (exit 0) when the spec is already published and no PR is missing; a branch already ahead + * of base still gets its PR ensured, so a failed prior run is repaired by the next release. */ import { $ } from "bun"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -27,7 +19,6 @@ const { values } = parseArgs({ options: { version: { type: "string" }, repo: { type: "string", default: "supabase/supabase" }, - // Path of the spec inside the docs repo, as the Go job passed it. "spec-path": { type: "string", default: "apps/docs/spec/cli_v1_commands.yaml" }, branch: { type: "string", default: "cli/ref-doc" }, base: { type: "string", default: "master" }, @@ -88,10 +79,8 @@ if (dryRun) { process.exit(0); } -// `--depth 1` is enough: when `cli/ref-doc` already exists its tip is fetched -// and the new spec lands as one commit on top, so commits pushed onto an open -// PR (sidebar fixes) survive later releases; otherwise the branch starts from -// base. +// `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec +// lands as one commit on top, so commits pushed onto an open PR survive later releases. const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); try { await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; diff --git a/apps/cli/scripts/publish.ts b/apps/cli/scripts/publish.ts index c86c11139b..6f06469641 100644 --- a/apps/cli/scripts/publish.ts +++ b/apps/cli/scripts/publish.ts @@ -45,16 +45,12 @@ const tagFlag = ["--tag", tag]; const provenanceFlag = ["--provenance"]; const noGitChecksFlag = ["--no-git-checks"]; -// Reads the active npm registry once. We honour `npm config get registry` -// rather than hard-coding registry.npmjs.org so the existence probe and the -// publish target stay aligned — important for the local Verdaccio harness -// (`pnpm local-registry`), which rewrites the global npm/pnpm registry config. +// Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and +// publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). const registryUrl = (await $`npm config get registry`.quiet().text()).trim().replace(/\/$/, ""); -// Probes the registry for `@`: -// 200 → already published, 404 → not, anything else throws. -// Used both as a pre-flight skip check and as a post-failure reconciliation -// when the actual publish errors with E403 (registry CDN cache may lag). +// Used as both a pre-flight skip check and post-failure reconciliation, since a publish can +// fail with E403 when the registry CDN cache lags behind an already-succeeded publish. async function isAlreadyPublished(name: string, version: string): Promise { const encodedName = name.replace("/", "%2F"); const res = await fetch(`${registryUrl}/${encodedName}/${version}`, { method: "GET" }); @@ -65,9 +61,8 @@ async function isAlreadyPublished(name: string, version: string): Promise @@ -133,14 +125,11 @@ const platformResults = await Promise.all( ), ); -// Build the umbrella package bin shim, then publish console.log("\nBuilding umbrella package shim..."); await $`pnpm build:shim`.cwd(cliDir); -// npm renders the README from the package directory on the package page. -// The workspace-internal apps/cli/README.md documents the source layout for -// contributors, which is not what we want users to see on npmjs.com. Copy -// the repo root README — the user-facing one — over it just before publish. +// npm renders the README from the package directory; apps/cli/README.md documents the source +// layout for contributors, not what npm users should see, so copy the root README over it. console.log("\nStaging root README for umbrella package..."); await copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); @@ -157,12 +146,9 @@ const skippedCount = results.filter((r) => r === "skipped").length; console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); -// All-skipped is ambiguous: it can mean "recovering from a downstream-only -// failure (GH release / brew / scoop) — bytes already on npm, just continue" -// OR "semantic-release re-computed a version whose bytes are already live, so -// today's commits silently did not ship". Since we cannot tell those apart -// here, log a loud warning so the human reviewing the workflow run can decide -// whether to re-cut as a fresh version via `workflow_dispatch`. +// All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that +// today's commits silently didn't ship a new version. Warn loudly so a human can decide whether +// to re-cut via workflow_dispatch. if (publishedCount === 0) { console.warn( `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + diff --git a/apps/cli/scripts/update-homebrew.ts b/apps/cli/scripts/update-homebrew.ts index d2f0fe8c6e..6266fef0e7 100644 --- a/apps/cli/scripts/update-homebrew.ts +++ b/apps/cli/scripts/update-homebrew.ts @@ -32,22 +32,17 @@ const dryRun = values["dry-run"]!; const root = path.resolve(import.meta.dir, "../../.."); const distDir = path.join(root, "dist"); -// Convert name (e.g. "supabase-beta") to the Ruby class name Homebrew -// expects (e.g. "SupabaseBeta"). The class + filename differ by channel so -// `supabase` and `supabase-beta` can coexist as separate formulas in the -// same tap, but the installed binary is always `supabase` (matching the -// Go CLI's historical behaviour). +// Converts name (e.g. "supabase-beta") to the Ruby class Homebrew expects (e.g. "SupabaseBeta"). +// The class and filename vary by channel, but the installed binary is always `supabase`. const className = name .split(/[-_]/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(""); -// `supabase-go` is the Go sidecar the CLI spawns via -// apps/cli/src/command-internal/go-proxy.layer.ts. It is looked up by exact -// filename colocated with process.execPath, so we MUST install it with its -// original name right next to the SFE. The `if File.exist?` guard keeps the -// formula working for a build that ships only the SFE. +// The Go sidecar is looked up by exact filename next to the running binary, so it must install +// under its original name; `if File.exist?` keeps the formula working when a build ships only +// the CLI binary. const installBlock = [ ` bin.install "supabase"`, ` bin.install "supabase-go" if File.exist?("supabase-go")`, @@ -55,7 +50,6 @@ const installBlock = [ const testInvocation = `#{bin}/supabase`; -// Parse checksums const checksums = new Map(); const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); for (const line of checksumsText.trim().split("\n")) { @@ -127,7 +121,6 @@ async function hasStagedChanges(repoDir: string, repoPath: string): Promise(); const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); for (const line of checksumsText.trim().split("\n")) { @@ -58,9 +54,8 @@ const baseUrl = local ? `file:///${distDir.replace(/\\/g, "/")}` : `https://github.com/${repo}/releases/download/v${version}`; -// Main-bucket layout uses unversioned Windows tarballs on GitHub Releases -// (release-shared.yml copies versioned builds to supabase_windows_*.tar.gz). -// Local builds only emit versioned archives, so --local keeps those names. +// Published releases use unversioned Windows tarball names (release-shared.yml copies the +// versioned build); local builds only emit versioned archives, so --local keeps those names. const amd64Tar = local ? `supabase_${version}_windows_amd64.tar.gz` : "supabase_windows_amd64.tar.gz"; @@ -121,7 +116,6 @@ async function hasStagedChanges(repoDir: string, repoPath: string): Promise`), invisible to the plain enumeration below, so -// it's swept separately. `findCredentials` decodes every matched blob and -// aborts entirely on one undecodable entry. +// Best-effort wipe of the "Supabase CLI" keyring namespace; errors are swallowed so one stuck +// credential can't abort logout. Windows credentials are also written under a separate +// target-shaped form, invisible to the plain enumeration, so they're swept separately. const deleteAllKeyringEntries = ( module: KeyringModule, platform: RuntimePlatform, @@ -337,10 +314,8 @@ const deleteAllKeyringEntries = ( } }); -// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches the shared -// credentials layer and the cli-e2e harness, which sets it). Without this, any -// unconditional keyring access — e.g. `unlink`'s credential delete — blocks on a -// Keychain authorization prompt in non-interactive / CI contexts. +// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely; without it, unconditional keyring +// access blocks on a Keychain authorization prompt in non-interactive/CI contexts. const loadKeyringModule = ( fs: FileSystem.FileSystem, ): Effect.Effect> => @@ -352,9 +327,8 @@ const loadKeyringModule = ( : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); }); -// Keyring chain for a given profile account: profile key first, then the -// legacy `access-token` key. The account parameter matters because Go keys -// the read on the RECONCILED `CurrentProfile.Name` (`access_token.go:43`). +// Keyring chain for a given profile account: profile key first, then the legacy `access-token` +// key. Callers must pass the already-reconciled profile name. const readKeyringForAccount = ( keyringModule: Option.Option, profileAccount: string, @@ -392,24 +366,17 @@ const readFallbackFile = ( }); /** - * Token resolution for an explicit profile account, mirroring the service's - * `getAccessToken` chain exactly: env token → keyring (profile account, then - * legacy account) → fallback file. Go resolves credentials AFTER - * `LoadProfile`, so the keyring account is the reconciled - * `CurrentProfile.Name` (`access_token.go:43`) — but the `CommandCredentials` - * service captures the config layer's profile at construction. Commands that - * reconcile a pflag-effective profile (sso add/update, PR #5974 round 9) - * resolve their token through this instead, keyed on the reconciled name. - * Fails with the same validation error as the service; callers absorb it the - * same way `resolveAccessToken` does. + * Resolves an access token for an explicit profile account: env token → keyring (profile + * account, then legacy account) → fallback file. Used by commands that reconcile a + * pflag-effective profile after `CommandCredentials` already captured a different one at + * construction. Fails with the same validation error as `resolveAccessToken`. */ export const accessTokenForProfile = Effect.fnUntraced(function* (profileAccount: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliSettings = yield* CommandSettings; - // `serviceOption` keeps the logger optional (no-op outside the real CLI - // tree), same as the sso pflag-reconcile module's optional services. + // Keeps the logger optional — a no-op outside the real CLI tree. const debugLogger: DebugLoggerShape = Option.getOrElse( yield* Effect.serviceOption(DebugLogger), () => ({ debug: () => Effect.void, http: () => Effect.void }), @@ -469,21 +436,19 @@ const makeCommandCredentials = Effect.gen(function* () { return CommandCredentials.of({ getAccessToken: Effect.gen(function* () { - // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliSettings.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); yield* validateAccessToken(Redacted.value(cliSettings.accessToken.value), "env"); return Option.some(cliSettings.accessToken.value); } - // Keyring (profile key, then legacy key). Skipped on WSL. + // Skipped on WSL. const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { yield* validateAccessToken(keyringValue.value, "stored"); return Option.some(Redacted.make(keyringValue.value)); } - // Filesystem fallback in the Supabase home directory. const fileValue = yield* readFile; if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); @@ -506,16 +471,15 @@ const makeCommandCredentials = Effect.gen(function* () { ); if (ok) return; } - // The containing directory is world-readable (0755); only the token - // FILE itself needs to be private (0600). + // The containing directory is world-readable (0755); only the token file itself must + // be private (0600). yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o755 }).pipe(Effect.orDie); yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie); }), deleteAccessToken: Effect.gen(function* () { - // 1. Always remove the fallback token file first. A missing file is - // ignored; any other removal failure aborts before the keyring is - // touched. + // Removes the fallback token file first; a missing file is ignored, but any other + // failure aborts before the keyring is touched. const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { yield* fs.remove(fallbackPath).pipe( @@ -529,15 +493,14 @@ const makeCommandCredentials = Effect.gen(function* () { ); } - // 2. Best-effort delete of the legacy `access-token` keyring account. - // Any error here is ignored — never affects the result. + // Best-effort delete of the legacy `access-token` keyring account; errors here don't + // affect the result. if (Option.isSome(keyringModule)) { yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); } - // 3. Delete the profile keyring account — this alone decides the outcome. - // No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps - // to `NotLoggedInError`. + // Deleting the profile keyring account decides the outcome; no keyring backend (WSL, + // `SUPABASE_NO_KEYRING`, unsupported) maps to `NotLoggedInError`. if (Option.isNone(keyringModule)) { return yield* Effect.fail(new NotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); } @@ -558,7 +521,7 @@ const makeCommandCredentials = Effect.gen(function* () { deleteProjectCredential: (projectRef: string) => Effect.gen(function* () { - // WSL / no keyring module: treated as `ErrNotSupported` — a no-op success. + // WSL or no keyring module: no-op success, nothing to delete. if (Option.isNone(keyringModule)) return false; return yield* deleteKeyringEntryStrict( keyringModule.value, diff --git a/apps/cli/src/auth/command-credentials.layer.unit.test.ts b/apps/cli/src/auth/command-credentials.layer.unit.test.ts index ba5c0806e4..b71c1caf22 100644 --- a/apps/cli/src/auth/command-credentials.layer.unit.test.ts +++ b/apps/cli/src/auth/command-credentials.layer.unit.test.ts @@ -23,8 +23,6 @@ import { commandCredentialsLayer } from "./command-credentials.layer.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; import { DeleteTokenError, InvalidAccessTokenError, NotLoggedInError } from "./errors.ts"; -// Keyring mock - const passwords = new Map(); let throwOnSetPassword = false; let throwOnSetSecret = false; @@ -99,8 +97,6 @@ vi.mock("@napi-rs/keyring", () => ({ }, })); -// Layer wiring - let tempHome: string; function makeLayer( @@ -400,10 +396,7 @@ describe("commandCredentialsLayer.saveAccessToken", () => { it.effect("falls back to the filesystem when the keyring write throws", () => { throwOnSetPassword = true; - // Deterministic mode assertions require a permissive umask: Go pins the - // fallback dir to 0755 (`access_token.go:91` → `MkdirIfNotExistFS`, - // `misc.go:273`, changed from the prior 0700) and the token file itself to - // 0600 (`access_token.go:94`). + // Deterministic mode assertions require a permissive umask. const prevUmask = process.umask(0); return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; @@ -433,9 +426,6 @@ describe("commandCredentialsLayer.saveAccessToken", () => { }); }); -// `deleteAccessToken` collapses three outcomes — logged out / not-logged-in / -// real failure — into the file + legacy-keyring + profile-keyring sequence. -// These cases assert that ordering and tri-state exactly. describe("commandCredentialsLayer.deleteAccessToken", () => { const seedTokenFile = (home: string, token = VALID_TOKEN) => { const supaDir = join(home, ".supabase"); @@ -482,7 +472,6 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); } - // File removal happens before the profile-keyring check (deliberate ordering). expect(tokenFileExists(tempHome)).toBe(false); }).pipe(Effect.provide(makeLayer())); }, @@ -533,8 +522,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const home = tempHome; const env = { HOME: home }; const tokenPath = join(home, ".supabase", "access-token"); - // Seed a profile keyring entry to prove the keyring is never touched once - // the file removal fails. + // Seeds a profile keyring entry to prove the keyring is never touched once the file removal fails. passwords.set("Supabase CLI/supabase", VALID_TOKEN); const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); const fsLayer = Layer.succeed( @@ -724,7 +712,6 @@ describe("commandCredentialsLayer.deleteAllProjectCredentials", () => { const { deleteAllProjectCredentials } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); expect(exit._tag).toBe("Success"); - // One undecodable entry aborts the whole findCredentials call. expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); @@ -760,7 +747,8 @@ describe("commandCredentialsLayer.deleteProjectCredential", () => { ); }); -// Suppress unused-import nag — referenced in JSDoc / used in assertions above. +// Kept to avoid an unused-import lint error; the classes are only referenced by name in +// assertions above. void InvalidAccessTokenError; void DeleteTokenError; void NotLoggedInError; diff --git a/apps/cli/src/auth/command-credentials.service.ts b/apps/cli/src/auth/command-credentials.service.ts index 828aa4a542..a19d6f0c93 100644 --- a/apps/cli/src/auth/command-credentials.service.ts +++ b/apps/cli/src/auth/command-credentials.service.ts @@ -15,40 +15,20 @@ interface CommandCredentialsShape { >; readonly saveAccessToken: (token: string) => Effect.Effect; /** - * Deletes the access token, in this exact order: - * - * 1. Remove `/access-token` first. A non-`ENOENT` removal error - * fails `DeleteTokenError`; a missing file is ignored. - * 2. Best-effort delete of the legacy `access-token` keyring account — any - * error other than not-found is swallowed and never affects the outcome. - * 3. Delete the profile keyring account (account = profile name). This - * **alone** decides the result: - * - keyring unavailable (no module / WSL / `SUPABASE_NO_KEYRING`) or the - * entry is absent → `NotLoggedInError`; - * - a real delete error → `DeleteTokenError`; - * - success → `void`. - * - * Deliberate quirk this preserves: on a no-keyring host the file is - * still removed, yet the call fails `NotLoggedInError` because the - * profile-keyring delete reports not-supported. + * Deletes the access token: removes the fallback file, best-effort deletes the legacy keyring + * account, then deletes the profile keyring account, which alone decides the result. The file + * is removed even on a no-keyring host, where the call still fails `NotLoggedInError`. */ readonly deleteAccessToken: Effect.Effect; /** - * Deletes **every** entry in the `"Supabase CLI"` keyring namespace (project - * database passwords stored by `link`). Best-effort: never fails, and is a - * no-op when the keyring is unavailable. Used by `supabase logout` after - * the access token is removed. + * Deletes every entry in the `"Supabase CLI"` keyring namespace (project database passwords + * stored by `link`). Best-effort: never fails, and no-ops when the keyring is unavailable. */ readonly deleteAllProjectCredentials: Effect.Effect; /** - * Deletes the stored database-password credential for a project from the OS - * keyring (keyring service `"Supabase CLI"`, account = the **project ref** — - * distinct from the access-token entry). Used by `supabase unlink`. - * - * Returns `true` when an entry was removed, `false` when none existed or the - * keyring is unavailable (WSL). Fails with `CredentialDeleteError` only - * for real keyring errors (e.g. permission denied); "not found" and - * "not supported" outcomes are not treated as failures. + * Deletes the stored database-password credential for a project from the OS keyring (account = + * the project ref). Returns `false` when none existed or the keyring is unavailable; fails + * with `CredentialDeleteError` only for a real keyring error. */ readonly deleteProjectCredential: ( projectRef: string, diff --git a/apps/cli/src/auth/command-platform-api-factory.layer.ts b/apps/cli/src/auth/command-platform-api-factory.layer.ts index 819cc7e99d..6c0adad60f 100644 --- a/apps/cli/src/auth/command-platform-api-factory.layer.ts +++ b/apps/cli/src/auth/command-platform-api-factory.layer.ts @@ -10,13 +10,10 @@ type CommandPlatformApiDeps = typeof makeCommandPlatformApi extends Effect.Effect ? R : never; /** - * Captures the surrounding Management API context without resolving an access - * token. The raw fetch client is provided here so `makeCommandPlatformApi` owns - * the single typed-API debug wrapper. - * - * `dohFetchLayer` overrides `FetchHttpClient.Fetch` so that when the - * factory's `make` resolves on the `--linked` path, the typed API client - * honours `--dns-resolver https`. + * Captures the surrounding Management API context without resolving an access token; the raw + * fetch client is provided here so `makeCommandPlatformApi` owns the single typed-API debug + * wrapper. `dohFetchLayer` overrides `FetchHttpClient.Fetch` so a factory-resolved `--linked` + * client honours `--dns-resolver https`. */ export const commandPlatformApiFactoryLayer = Layer.effect( CommandPlatformApiFactory, diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 7d3e8a97e4..9e4fdbbd53 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -18,11 +18,9 @@ export const makeCommandPlatformApi = Effect.gen(function* () { const cliSettings = yield* CommandSettings; const credentials = yield* CommandCredentials; const debugLogger = yield* DebugLogger; - // Every Management API response is passed through the per-command identity - // stitcher for session identity stitching. Consume the single per-command - // stitcher service rather than building one here, so the typed client shares - // the one `stitchAttempted` guard with the raw advisor GETs and the - // linked-project cache; otherwise each transport would re-alias/re-persist. + // Every Management API response goes through the per-command identity stitcher. Consuming + // the shared service, rather than building one here, keeps its `stitchAttempted` guard shared + // with the raw advisor GETs and the linked-project cache. const { stitch: stitchIdentityFromResponse } = yield* IdentityStitch; const transformClient = (client: HttpClient.HttpClient) => { @@ -41,9 +39,9 @@ export const makeCommandPlatformApi = Effect.gen(function* () { const resolveAccessToken = Effect.gen(function* () { if (Option.isSome(configuredToken)) { yield* debugLogger.debug("Using access token from env var..."); - // credentials.getAccessToken already validates the keyring/file paths; - // validate the env token here too so a malformed SUPABASE_ACCESS_TOKEN - // fails with the invalid-token error rather than being sent to the API. + // credentials.getAccessToken already validates the keyring/file paths; validate the env + // token here too so a malformed SUPABASE_ACCESS_TOKEN fails with the invalid-token error + // rather than being sent to the API. yield* validateAccessToken(Redacted.value(configuredToken.value), "env"); return configuredToken; } diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index cfa76c3bae..362c72b9a1 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -173,11 +173,8 @@ function withBaseDeps( } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); - // The typed client now consumes the single `IdentityStitch` service rather - // than building its own stitcher, so build that service from the test's - // Analytics / TelemetryRuntime / FileSystem / Path fakes and provide it. The - // underlying stitch behaviour is identical (the service wraps the same - // `makeIdentityStitcher`), so all alias/persist assertions still hold. + // Builds the shared `IdentityStitch` service from the test's Analytics/TelemetryRuntime/ + // FileSystem/Path fakes; alias/persist assertions rely on this real service instance. const identityStitch = identityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( @@ -281,9 +278,6 @@ describe("commandPlatformApiLayer", () => { it.effect( "fails with the invalid-token error when the env token is malformed (Go parity)", () => { - // The env token is validated against the sbp_ pattern before any API - // call; a malformed SUPABASE_ACCESS_TOKEN must fail with the - // invalid-token error, not be sent to the API. const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), @@ -530,11 +524,9 @@ describe("commandPlatformApiLayer", () => { }); }); -// The lazy factory underpins the `--linked` db-config resolver's auth-free -// `--password` path, which only loads a token when no password is supplied. -// Building the factory must therefore resolve NO token; the friendly auth -// error must still surface when a command branch actually reaches `make` -// (e.g. minting a temp role). +// The lazy factory backs the `--linked` db-config resolver's auth-free `--password` path, which +// must build without resolving a token but still surface the auth error once `make` is actually +// reached. describe("commandPlatformApiFactoryLayer (lazy token)", () => { it.effect("builds without resolving an access token even when none is configured", () => { const layer = commandPlatformApiFactoryLayer.pipe( @@ -542,9 +534,6 @@ describe("commandPlatformApiFactoryLayer (lazy token)", () => { Layer.provide(mockCredentials(Option.none())), withBaseDeps(), ); - // The eager `commandPlatformApiLayer` would fail to build here; obtaining the - // factory service without touching `make` must succeed — this is exactly the - // `--linked --password` path, which never mints a temp role. return Effect.gen(function* () { const factory = yield* CommandPlatformApiFactory; expect(typeof factory.make).toBe("object"); @@ -579,8 +568,6 @@ describe("commandPlatformApiFactoryLayer (lazy token)", () => { const factory = yield* CommandPlatformApiFactory; const first = yield* factory.make; const second = yield* factory.make; - // `Effect.cached` guarantees the token is resolved once and the same client - // instance is reused across repeated `make` calls within one command run. expect(first).toBe(second); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/auth/errors.ts b/apps/cli/src/auth/errors.ts index a997734314..01bb7d2a09 100644 --- a/apps/cli/src/auth/errors.ts +++ b/apps/cli/src/auth/errors.ts @@ -58,11 +58,9 @@ export class NotLoggedInError extends Data.TaggedError("NotLoggedInError")<{ } /** - * Raised by `deleteAccessToken` when removing the token fails for a real reason - * — a non-`ENOENT` failure removing `/access-token`, or a non - * not-found error deleting the profile keyring entry. Mirrors Go's - * `failed to remove access token file: …` / `failed to delete access token from - * keyring: …` errors (`access_token.go:100-119`), which exit 1. + * Raised by `deleteAccessToken` when removing the token fails for a real reason — a non-`ENOENT` + * failure removing `/access-token`, or a non not-found error + * deleting the profile keyring entry. */ export class DeleteTokenError extends Data.TaggedError("DeleteTokenError")<{ readonly message: string; diff --git a/apps/cli/src/auth/http-debug.layer.ts b/apps/cli/src/auth/http-debug.layer.ts index b8155e9db2..08239b0a80 100644 --- a/apps/cli/src/auth/http-debug.layer.ts +++ b/apps/cli/src/auth/http-debug.layer.ts @@ -30,19 +30,10 @@ const PRESIGNED_QUERY_KEYS = [ ]; /** - * The URL as it should appear in a debug log: unchanged, unless its query string - * carries a signature, in which case the query is replaced wholesale. - * - * Redacting the whole query rather than the matched parameters keeps the - * decision simple and cannot leak a sibling parameter that turns out to matter. - * The path survives, which is what makes the line useful for debugging in the - * first place. - * - * A denylist of known signature parameters, so it is by nature incomplete: a - * provider spelling its signature something new would log verbatim until the - * list learns about it. The alternative — redacting every query string — would - * cost the debug log its usefulness on the Management API calls that are the - * whole reason `--debug` exists. Add spellings here as they turn up. + * The URL as it should appear in a debug log: unchanged, unless its query string carries a + * signature, in which case the whole query is replaced — not just the matched parameter, so a + * sibling parameter can't leak — while the path survives for debugging. The signature denylist + * is inherently incomplete; add new spellings as they turn up. */ export function redactHttpUrl(url: string): string { let parsed: URL; @@ -65,12 +56,9 @@ export function redactHttpUrl(url: string): string { } /** - * Wraps `FetchHttpClient.layer` so every HTTP request goes through the CLI's - * debug side channel. The logger itself owns the `--debug` guard and the - * line formatting. - * - * `dohFetchLayer` overrides `FetchHttpClient.Fetch` with a - * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the CLI's debug side channel; + * the logger owns the `--debug` guard and line formatting. `dohFetchLayer` overrides + * `FetchHttpClient.Fetch` with a DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. */ export const httpClientLayer = Layer.effect( HttpClient.HttpClient, diff --git a/apps/cli/src/auth/http-debug.unit.test.ts b/apps/cli/src/auth/http-debug.unit.test.ts index f5d120658b..f60d419269 100644 --- a/apps/cli/src/auth/http-debug.unit.test.ts +++ b/apps/cli/src/auth/http-debug.unit.test.ts @@ -1,12 +1,6 @@ import { describe, expect, test } from "vitest"; import { redactHttpUrl } from "./http-debug.layer.ts"; -/** - * `--debug` logs every request URL to stderr. For a presigned object-store URL - * the query string *is* the credential — for the Workers build-context upload, - * one that authorizes overwriting the archive a deploy is about to build from — - * so it must not survive into scrollback or a CI log. - */ describe("redactHttpUrl", () => { test.each([ [ @@ -34,8 +28,6 @@ describe("redactHttpUrl", () => { expect(redactHttpUrl(url)).not.toContain("deadbeef"); }); - // The debug log is only useful if ordinary requests still read normally, so - // redaction has to be the exception rather than the rule. test.each([ ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], diff --git a/apps/cli/src/cli/agent-output.e2e.test.ts b/apps/cli/src/cli/agent-output.e2e.test.ts index 0233746952..cc2c44de9f 100644 --- a/apps/cli/src/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/cli/agent-output.e2e.test.ts @@ -16,11 +16,7 @@ describe("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. + // stdout carries exactly one JSON error line; the library's help doc goes to stderr. expect(parseJsonLines(stdout)).toEqual([ expect.objectContaining({ _tag: "Error", @@ -39,7 +35,6 @@ describe("CLI agent output", () => { ); expect(exitCode).toBe(1); - // 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"'); diff --git a/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts b/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts index c84cb843be..0b52cc503f 100644 --- a/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts +++ b/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts @@ -9,43 +9,25 @@ import { unwrapParam } from "../command-internal/param-introspection.ts"; import { rootCommand } from "./root.ts"; /** - * `Flag.boolean(name)` builds a bare `Single` param, and a bare `Single` is - * *required* — omitting it fails the whole command with a missing-flag error - * before the handler ever runs. Every boolean flag therefore has to be closed - * off with `Flag.withDefault(false)` or `Flag.optional`. - * - * Nothing else catches this: handler integration tests build their flags record - * directly, so they never touch the parser, and the required-ness is invisible - * to the type checker because a required boolean flag still infers as - * `boolean`. The flag only misbehaves when a real invocation omits it, which is - * precisely the invocation no handler test makes — so the guard walks the - * command tree instead of waiting for a command to be exercised end to end. - * - * `experimental workers push --no-wait` is the flag that prompted it: it first - * shipped with neither closer, which made a plain - * `supabase experimental workers push` fail to parse at all. + * `Flag.boolean(name)` alone builds a required param, so omitting it fails the whole command + * with a missing-flag error before the handler runs — every boolean flag must pair with + * `Flag.withDefault(false)` or `Flag.optional`. No other test catches this: handler integration + * tests build their flags record directly, bypassing the parser, and the type checker can't see + * the required-ness since it still infers as `boolean`. */ /** - * The published getter for a primitive's kind — `Primitive.getTypeName`, whose - * own doc example pins `Primitive.boolean` to `"boolean"`. Reading - * `primitiveType._tag` instead would couple this guard to effect's runtime - * representation, which this repo forbids in tests as well as in source. - * - * Derived from `Primitive.boolean` rather than written as the literal - * `"boolean"`: were that name to change upstream, a hardcoded literal would - * match nothing and leave the guard silently passing every command, which is - * the one failure mode a regression test must not have. + * Uses `Primitive.getTypeName(Primitive.boolean)` rather than the literal `"boolean"`, so a + * rename upstream breaks loudly instead of silently matching nothing and passing every command. + * Avoids `primitiveType._tag` to keep this guard off effect's runtime representation. */ const BOOLEAN_TYPE_NAME = Primitive.getTypeName(Primitive.boolean); function booleanFlagsRequiringAValue(command: Command.Command.Any): ReadonlyArray { const internals = commandInternals(command); - // All three parameter sets a command can be parsed with, not just its own: - // `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the - // root's persistent flags arrive as `globalFlags`. A bare boolean introduced - // through either would break every command that inherits it while a guard - // reading only `config.flags` stayed green. + // Checks all three parameter sets a command can be parsed with, not just its own: + // `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the root's persistent + // flags arrive as `globalFlags`. const params = [ ...internals.config.flags, ...internals.contextConfig.flags, diff --git a/apps/cli/src/cli/complete.integration.test.ts b/apps/cli/src/cli/complete.integration.test.ts index 502f4b3fc4..d829e10260 100644 --- a/apps/cli/src/cli/complete.integration.test.ts +++ b/apps/cli/src/cli/complete.integration.test.ts @@ -6,13 +6,9 @@ import { EventCommandExecuted, PropExitCode } from "../shared/telemetry/event-ca import { captureCompleteTelemetryEffect, tryComplete, type CompleteDeps } from "./complete.ts"; import { rootCommand } from "./root.ts"; -// `mockAnalytics()` (`tests/helpers/mocks.ts`, the double `bash.integration.test.ts` -// uses for the same `cli_command_executed` assertion on the static completion -// leaves) records only the direct `capture(event, properties)` arguments — it -// never reads `CurrentAnalyticsContext`, so it can't see the `command` value -// `withAnalyticsContext` attaches. This local double mirrors the REAL -// `analyticsLayer`'s own capture implementation just enough to merge -// that context in, so this file can assert on `command` too. +// Mirrors the real `analyticsLayer`'s capture implementation just enough to merge +// `CurrentAnalyticsContext` into captured events, since the shared `mockAnalytics()` double +// doesn't read that context. function mockAnalyticsWithContext() { const captured: Array<{ event: string; diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index c6d806a94e..310d594cd5 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -23,43 +23,13 @@ import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; /** - * Native TypeScript reimplementation of cobra's dynamic-completion protocol - * (`spf13/cobra@v1.10.2/completions.go`). Cobra-generated - * completion scripts (`supabase completion {bash,zsh,fish,powershell}`) call - * back into `supabase __complete ` on every tab press — or - * `supabase __completeNoDesc ` when the script was generated with - * `--no-descriptions` (cobra's alias for the same hidden command). This module - * bypasses Effect's structured argv parser entirely for that path (the args may - * include partial/malformed flag tokens, e.g. `--de` mid-completion, that the - * parser would reject) and instead reflects directly over `rootCommand` — the - * live Effect CLI command tree — to compute candidates. - * - * Deliberate, documented simplifications relative to real cobra: - * - No `--help`-style multi-paragraph usage error for zero completion args - * (`MinimumNArgs(1)` failure) — real generated shell scripts always pass at - * least one arg, so this path is realistically unreachable by real - * completion traffic. - * - No "Completion ended with directive: ..." trailer or `[Debug] [Error] ...` - * diagnostics — both are cobra-side stderr-only text every real generated - * completion script discards (`2>/dev/null` or equivalent), so reproducing - * them has zero observable effect on any user. - * - Mutually-exclusive flag-group hiding (cobra's `enforceFlagGroupsForCompletion`) - * is not reproduced — there is no equivalent flag-group annotation anywhere in - * this TS tree to mirror, and hand-building a shadow table carries a - * materially higher transcription-error risk than the small, stable tables - * below. Accepted as a documented gap. - * - Deprecated commands/flags (cobra's `IsAvailableCommand()`/`MarkDeprecated`) - * are not filtered out of candidates — this TS tree has no "deprecated" - * concept distinct from `hidden` today (deprecation is only reflected in - * description text), so filtering it out here would require tree-level - * metadata this port doesn't own. Accepted as a documented gap, expected to - * shrink as the tree's own deprecated-alias cleanup lands separately. + * Implements the shell completion protocol that cobra-generated scripts (`supabase + * completion {bash,zsh,fish,powershell}`) call into via `supabase __complete`/ + * `__completeNoDesc` on every tab press. Completion argv can contain partial or malformed + * flag tokens (e.g. `--de` mid-word), so this bypasses the structured CLI parser and + * reflects directly over `rootCommand` to compute candidates. */ -/* ========================================================================== */ -/* Types */ -/* ========================================================================== */ - export interface CompletionCandidate { readonly name: string; readonly description: string | undefined; @@ -70,9 +40,7 @@ export interface CompletionResult { readonly directive: number; } -/** - * The subset of cobra's `ShellCompDirective` bit flags this port ever emits. - */ +/** Bit flags for the shell completion protocol's directive value. */ export const CompletionDirective = { Default: 0, NoFileComp: 4, @@ -116,41 +84,16 @@ export interface CompleteDeps { readonly stdoutWrite: (message: string) => void; readonly stderrWrite: (message: string) => void; readonly exit: (code: number) => void; - /** - * Fires the `cli_command_executed` telemetry capture for this request — - * see `captureCompleteTelemetryEffect`'s doc comment for what it - * records and why. Injected the same way `stdoutWrite`/`exit` already are, - * so tests can run `captureCompleteTelemetryEffect` against a mocked - * `Analytics` boundary without spawning a real subprocess or touching the - * real, consent-gated production layer `defaultCompleteDeps` wires by - * default — see `complete.integration.test.ts`. - */ + /** Fires the `cli_command_executed` telemetry capture for this request. */ readonly captureTelemetry: (exitCode: number, durationMs: number) => Promise; } -/* ========================================================================== */ -/* Internal command field access (`param-introspection.ts` precedent) */ -/* ========================================================================== */ - /** - * `.config.flags` (a command's own declared flags), `.contextConfig.flags` - * (flags inherited via `Command.withSharedFlags`), and `.globalFlags` (a - * command's own declared global flags) are genuinely absent from the public - * `Command`/`Command.Any` TypeScript interface — only `name`, `description`, - * `shortDescription`, `alias`, `examples`, `subcommands`, `annotations`, and - * `hidden` are public — but they exist at runtime (`internal/command.ts`'s - * `makeCommand`, via `Object.assign`; that internal module is not importable - * — its package.json export map entry is `null` — so there is no type-safe - * import to reach for instead). - * - * A bare `as unknown as` here would silently paper over that gap (forbidden - * by this repo's typing rules — see `CLAUDE.md`), so this narrows through a - * runtime type guard instead, the same `"" in value` shape - * `param-introspection.ts`'s `isWrappedParam` already - * establishes for the identical problem (an internal-only field the public - * `effect/unstable/cli` types don't declare). If a future `effect` version - * ever drops one of these fields, this throws instead of silently completing - * against `undefined`. + * `config.flags`, `contextConfig.flags`, and `globalFlags` exist on `Command` + * at runtime but aren't part of its public type. Narrow through a runtime + * guard rather than an `as` cast, so a future `effect` upgrade that drops one + * of these fields throws here instead of silently completing against + * `undefined`. */ interface CommandInternal { readonly config: { readonly flags: ReadonlyArray }; @@ -177,19 +120,11 @@ function flattenSubcommands(command: Command.Command.Any): ReadonlyArray group.commands); } -/* ========================================================================== */ -/* Flag descriptors */ -/* ========================================================================== */ - /** - * `Flag.choice`/`Flag.choiceWithValue`'s `choiceKeys` (the valid value set) is - * attached to the `Choice`-tagged `Primitive` via `Object.assign` at - * runtime (`Primitive.choice`, - * `.repos/effect/packages/effect/src/unstable/cli/Primitive.ts`) but carries - * an `@internal` JSDoc tag and is absent from the public `Primitive` - * interface — the identical gap `CommandInternal` above already works - * around for `Command`, so this reuses the same runtime type-guard idiom - * instead of an `as` cast. + * `choiceKeys` (a `Choice` primitive's valid value set) is attached via + * `Object.assign` at runtime and isn't part of the public `Primitive` + * type, so this narrows through the same runtime-guard idiom as + * `CommandInternal`. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray; @@ -222,29 +157,10 @@ function flagDescriptorFromParam(param: Param.AnyFlag): FlagDescriptor | undefin } /** - * The full in-scope flag list for `commandChain`'s last element (the resolved - * command), ordered and grouped the way cobra's own completion path emits - * flag-name candidates: `InheritedFlags().VisitAll` (every ancestor's global - * and shared flags, as ONE pflag-alphabetically-sorted block), followed by - * `NonInheritedFlags().VisitAll` (the resolved command's own global flags, - * its own `--help`, the `--log-level`/`--wizard`/`--completions` built-ins, - * root's own `--version`, and its own local flags, as a SECOND, - * separately-sorted block) — pflag's `FlagSet.VisitAll` walks - * `sortedFormalFlags`, which sorts strictly by each flag's canonical long - * name: `db dump -` lists `--agent`, `--create-ticket`, `--debug`, ... - * alphabetically, THEN a second alphabetical run starting `--data-only`, - * `--db-url`, `--dry-run`, ... — not one merged alphabetical list and not - * this tree's own declaration order. - * - * The resolved command's own local flags win on a canonical-name collision — - * e.g. a command's own local `--output` (`db diff`'s file-path flag) must - * shadow the global `--output` choice flag declared at root — by being - * excluded from the inherited block entirely, mirroring pflag's - * `InheritedFlags()`, which skips any persistent flag shadowed by a - * same-named local one (rather than being present in both and "last write - * wins": a `Map`'s insertion-order position does not move on a same-key - * `.set()`, so a naive later-overwrite would leave the shadowed entry sitting - * in the wrong (inherited) sort position instead of removing it). + * Returns in-scope flags as two alphabetically sorted blocks — inherited + * flags, then the command's own — the order completion scripts expect. A + * local flag shadows a same-named inherited one rather than appearing twice + * (e.g. `db diff`'s own file-path `--output` shadows the global choice flag). */ export function collectInScopeFlags( root: Command.Command.Any, @@ -263,19 +179,12 @@ export function collectInScopeFlags( const ownParams: Array = [ ...globalFlagParamsOf(finalCommand), GlobalFlag.Help.flag, - // The built-ins `--log-level`, `--wizard`, and `--completions` are part - // of every resolved command's real flag set (`GlobalFlag.BuiltIns`, shown - // in `--help`), so the strict flag walk must resolve them or a typed - // `--log-level error` poisons the whole line like an unknown flag - // (issue #6482). Unlike `--help`/root `--version`, whose short-circuit - // below ends the completion request, these three keep the line - // completing normally. + // These built-ins must resolve like any other known flag, or a typed + // `--log-level error` poisons the rest of the completion line. GlobalFlag.LogLevel.flag, GlobalFlag.Wizard.flag, GlobalFlag.Completions.flag, - // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on - // the root command (gated on `c.Version != ""`, and non-persistent) — it - // is never inherited by subcommands the way `--help` is. + // `--version` only applies to the root command; it isn't inherited by subcommands. ...(commandChain.length === 1 ? [GlobalFlag.Version.flag] : []), ...internalCommand(finalCommand).config.flags, ]; @@ -298,22 +207,12 @@ export function collectInScopeFlags( return [...inherited, ...own]; } -/* ========================================================================== */ -/* Flag-token resolution */ -/* ========================================================================== */ - /** - * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster - * like `-po`, where cobra's rule is "the character immediately before the - * value/`=`", i.e. the last character) to its owning in-scope flag. Mirrors - * cobra's `checkIfFlagCompletion` heuristic (`completions.go:676-681,702-707`, - * the documented `-asd` => `d` quirk from cobra issue #1257) for guessing - * which flag the CURRENT or immediately PRECEDING token is mid-way through - * value-completing — deliberately NOT the same algorithm as - * `resolveShortFlagCluster`, which mirrors the real, strict - * `ParseFlags()` parser instead (first character owns the value, not last). - * See that function's doc comment for why the two differ and where each is - * used. + * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster like `-po`) to + * its owning in-scope flag, guessing which flag the current or preceding token is mid-way + * through value-completing by treating the character immediately before the value/`=` (the + * last character of a cluster) as the owner. This differs from `resolveShortFlagCluster`, + * which mirrors the strict parser's first-character rule; see that function's doc comment. */ function resolveFlagFromToken( token: string, @@ -330,57 +229,12 @@ function resolveFlagFromToken( return undefined; } -/* ========================================================================== */ -/* Command-path resolution */ -/* ========================================================================== */ - /** - * Descends from `root` through `trimmedArgs`, matching each non-flag token - * against the current command's subcommand names/aliases (exact, - * case-sensitive — no prefix or fuzzy matching). Mirrors cobra's `Find()`, - * which strips flags before matching positional command names - * (`completions.go:340`) via its own heuristic `stripFlags` - * (`pflag@v1.0.9/flag.go`) — a cruder, command-tree-only pre-pass distinct - * from the real flag parser `collectChangedFlagNames` mirrors: - * - * - A long flag (`--foo`) or a single-character short flag (`-f`) with no - * embedded `=` consumes the following token as its value UNLESS it's - * already known at this point in the descent to be boolean — this - * includes flags not yet in scope, e.g. a subcommand's own local flag - * typed before that subcommand is reached (`--db-url`, local to `db - * dump`, typed before `db`): `stripFlags`'s `hasNoOptDefVal` returns - * `false` for a name it can't find yet, so `!hasNoOptDefVal(...)` is - * `true` and it optimistically consumes a value anyway: `__complete - * --db-url postgres:// db dump --s` still offers `db dump`'s `--schema`, - * which requires descending past `--db-url postgres://` to reach `db - * dump` at all. - * - Anything else flag-shaped — a multi-character shorthand cluster - * (`-rj`), a flag containing `=`, or a bare `--` — is skipped without - * consuming a value. A bare `--` additionally stops the descent - * entirely: it's pflag's end-of-flags sentinel, so no token at or after - * it can ever match a subcommand (verified empirically: `__complete -- - * db ""` returns zero candidates with the Default directive, not `db`'s - * subcommands). - * - A bare `-` is NOT flag-shaped at all — pflag's own `isFlagArg` - * (`command.go:750-753`) requires at least 2 characters, so cobra's - * `stripFlags` (`command.go:674-706`) silently drops it from its - * subcommand-name scan (it matches none of that function's `switch` - * cases) without either consuming a value OR stopping the descent, and — - * critically — WITHOUT removing it from the leftover args the way a - * matched command name is (`argsMinusFirstX` only ever strips the exact - * matched name). It therefore must stay in `leftoverArgs` here too, while - * still letting the descent continue past it: `db - dump --da` still - * descends past the bare `-` into `dump` and offers `--data-only`, while - * `sso - --debug a` returns zero candidates with the Default directive — - * the surviving `-` keeps the `len(finalArgs) == 0` subcommand-listing - * gate below closed. - * - * Descent stops at the first non-flag token that doesn't match a subcommand, - * or at a `--` sentinel; that token and everything after it becomes - * `leftoverArgs` — the *positional* leftover cobra's `finalArgs` represents - * (`completions.go:397-399`), used to gate subcommand-name completion - * (`len(finalArgs) == 0`). Flag tokens and their consumed values are never - * part of `leftoverArgs`; a bare `-` is the one exception, per above. + * Descends from `root` through `trimmedArgs`, matching each non-flag token against the + * current command's subcommand names/aliases. A flag not yet known to be boolean is assumed + * to consume the next token as its value, even if the flag itself isn't in scope yet. A bare + * `--` stops the descent (end of flags); a bare `-` does not, but stays in `leftoverArgs` + * unlike a matched command name. */ export function resolveCommandPath( root: Command.Command.Any, @@ -399,14 +253,11 @@ export function resolveCommandPath( continue; } - if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this can match a subcommand. + if (token === "--") break; // end of flags: nothing at or after this can match a subcommand. if (token === "-") { - // Not flag-shaped (pflag's `isFlagArg` requires length >= 2) and never - // a real subcommand name — skip it without consuming a value, without - // breaking the descent, and WITHOUT marking it consumed, so it survives - // into `leftoverArgs` exactly like real cobra's `finalArgs` does. See - // this function's doc comment for the empirical verification. + // Not flag-shaped and never a subcommand name; skip without stopping the descent, but + // leave it in `leftoverArgs` unlike a matched command name. index++; continue; } @@ -416,13 +267,10 @@ export function resolveCommandPath( const isLong = token.startsWith("--"); const isSingleCharShort = !isLong && token.length === 2; if (!token.includes("=") && (isLong || isSingleCharShort)) { - // The flags visible at this point of the descent are enough to tell - // whether this token consumes the next one as its value. const inScopeSoFar = collectInScopeFlags(root, commandChain); const resolved = resolveFlagFromToken(token, inScopeSoFar); - // An unrecognized flag is optimistically assumed to take a value too - // (see the doc comment above) — only a flag already known here to be - // boolean is exempt. + // An unrecognized flag is assumed to take a value too; only a flag already known + // here to be boolean is exempt. const takesValue = resolved === undefined || !resolved.isBoolean; if (takesValue && index + 1 < trimmedArgs.length) { consumedIndices.add(index + 1); @@ -449,14 +297,8 @@ export function resolveCommandPath( return { commandChain, matchedPath, leftoverArgs }; } -/* ========================================================================== */ -/* Classification */ -/* ========================================================================== */ - /** - * File-extension filtering for a small, fixed set of file-path flags — not - * derived from anything generic, so a small matching lookup table here is - * the right level of fidelity. Key = + * File-extension filtering for a small, fixed set of file-path flags. Key = * `:`. */ const COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap> = new Map([ @@ -467,30 +309,19 @@ const COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap ]); /** - * The set of flags cobra treats as unconditionally required during - * `__complete`/`__completeNoDesc`. A flag whose requirement is scoped inside - * a `PreRun`/`PreRunE` hook (conditional on other flags or TTY state) never - * applies to a real completion request, since completion never runs those - * hooks — deliberately excluded here for that reason: `db dump:data-only` - * (inside `PreRun`), `init:experimental` (inside `PreRun`), `projects - * create:{org-id,db-password,region}` (inside `PreRunE`), `link:project-ref` - * (inside `PreRunE`). - * - * Deliberately a hardcoded table, not derived from whether the TS flag is - * `Flag.optional`-wrapped: several of these TS flags are intentionally - * `Flag.optional` at parse time for validation-ordering reasons unrelated to - * completion (e.g. `vanity-subdomains activate --desired-subdomain` — see - * that command's own file comment), so "is this flag `Optional`-wrapped in - * TS" is not a faithful proxy for "does cobra mark it required." Key = - * `:`. + * Flags treated as required during completion. A flag whose requirement is conditional on + * other flags or TTY state is excluded, since completion never evaluates those conditions. + * This is a hardcoded table rather than derived from `Flag.optional`, since some flags are + * `Flag.optional` at parse time for validation-ordering reasons unrelated to completion. Key + * = `:`. */ const COMPLETION_REQUIRED_FLAGS: ReadonlySet = new Set([ - "domains create:custom-hostname", // cmd/domains.go:100 - "migration repair:status", // cmd/migration.go:122 - "gen bearer-jwt:role", // cmd/gen.go:175 - "sso add:type", // cmd/sso.go:165 - "vanity-subdomains activate:desired-subdomain", // cmd/vanitySubdomains.go:67 - "vanity-subdomains check-availability:desired-subdomain", // cmd/vanitySubdomains.go:69 + "domains create:custom-hostname", + "migration repair:status", + "gen bearer-jwt:role", + "sso add:type", + "vanity-subdomains activate:desired-subdomain", + "vanity-subdomains check-availability:desired-subdomain", ]); function isRequiredCompletionFlag(matchedPath: ReadonlyArray, flagName: string): boolean { @@ -498,16 +329,9 @@ function isRequiredCompletionFlag(matchedPath: ReadonlyArray, flagName: } /** - * Mirrors cobra's `InitDefaultCompletionCmd`, which registers - * `ValidArgsFunction: NoFileCompletions` on the `completion` group command - * itself and each of its `bash`/`zsh`/`fish`/`powershell` leaves. - * `getCompletions` always calls a resolved command's own - * `ValidArgsFunction` when one is registered, and that call OVERWRITES the - * directive outright — for a leaf like `completion bash`, which has no - * subcommands of its own to otherwise set NoFileComp, this is the ONLY - * thing that sets it: `completion bash ""` returns the NoFileComp directive - * with zero candidates, not Default. Key = space-joined `matchedPath` - * (excluding "supabase"). + * Commands whose completion always returns the `NoFileComp` directive with + * zero candidates, since they take no positional arguments to complete. Key = + * space-joined `matchedPath` (excluding "supabase"). */ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet = new Set([ "completion", @@ -534,47 +358,11 @@ function flagNameCandidates( } /** - * A lightweight, string-only approximation of "which in-scope flags have - * already been provided" (not a real flag parser, but close enough to mirror - * pflag's actual `Set`-time behavior for the shapes real completion input - * takes) — correct for the overwhelming majority of real completion inputs. - * - * Stops at a bare `--` the same way `findUnresolvedFlagToken` and - * `resolveCommandPath` do — pflag's end-of-flags sentinel means - * nothing at or after it is ever parsed as a flag, so nothing past it can be - * "changed": `sso add -- --type --typ` still offers `--type`, since that - * token is positional, past the terminator, and never reaches pflag's flag - * parser at all. - * - * A LONG flag with no `=` that resolves to a non-boolean in-scope flag - * consumes the immediately following token as its value — that token is - * skipped here entirely, exactly like pflag's `parseLongArg` - * (`pflag@v1.0.10/flag.go:1013-1023`), so a value that happens to look like a - * flag (e.g. `--domains --type foo`, where `--type` is `--domains`'s value) - * is never itself marked changed. - * - * A short-flag token walks its shorthand cluster exactly like - * `pflag@v1.0.10`'s `parseSingleShortArg`: each character that resolves to a - * boolean (`NoOptDefVal != ""`) flag is marked changed and the walk continues - * to the next character in the SAME token; the first non-boolean character - * (or a `=value` suffix) is also marked changed but ends the walk there, - * since the rest of the token (or the next arg) is that flag's value, not - * another shorthand: after `storage cp -rj 2`, both `-r`/`--recursive` and - * `-j`/`--jobs` are "changed" — `--r` offers nothing further — whereas - * this function used to record only the cluster's last character. - * - * `classifyCompletion`'s `--help`/`--version` short-circuit reads THIS - * set (`changedFlagNames.has("help"/"version")`) rather than scanning raw - * tokens for a reason beyond DRY: pflag's `boolValue.Set` marks the flag - * `Changed` on an explicit-value spelling too (`--help=false`), and cobra's - * `helpOrVersionFlagPresent` checks `.Changed`, not the parsed value — so - * `--help=false`/`--version=false` short-circuit exactly like a bare - * `--help`/`--version`: `--help=false --d` and `--version=false br` both - * return zero candidates with the NoFileComp directive — and this - * function's name-collection above already marks a flag changed on ANY - * spelling, explicit-value included. A raw token scan misses the terminator- - * and value-consumption cases this function already handles instead (see - * `classifyCompletion`'s call site for the specific repros). + * A lightweight, string-only approximation of "which in-scope flags have already been + * provided", accurate for the input shapes shells actually send. Stops at a bare `--` + * terminator; a value-taking flag consumes its following token so that value is never + * itself mistaken for a flag; an explicit `--help=false`/`--version=false` still counts + * as "provided", matching how "changed" flag state is tracked independent of its value. */ function collectChangedFlagNames( trimmedArgs: ReadonlyArray, @@ -586,7 +374,7 @@ function collectChangedFlagNames( const token = trimmedArgs[index]; index++; if (token === undefined) continue; - if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this is parsed as a flag. + if (token === "--") break; // end of flags: nothing at or after this is parsed as a flag. if (token.startsWith("--")) { const rest = token.slice(2); @@ -608,14 +396,9 @@ function collectChangedFlagNames( } /** - * Walks a short-flag token's shorthand cluster (e.g. `-rj`, `-o=json`), - * marking every shorthand consumed before — and including — the - * value-consuming one as changed. Returns `true` when the cluster ends on a - * non-boolean shorthand with no attached value (`-f`, or `-rf` ending on - * `f`) — the caller must then skip the immediately following token, since - * pflag consumes it as that shorthand's value rather than parsing it as its - * own flag. See `collectChangedFlagNames`'s doc comment for the pflag - * behavior this mirrors. + * Walks a short-flag cluster (e.g. `-rj`, `-o=json`), marking every shorthand up to and + * including the value-consuming one as changed. Returns `true` when the cluster ends on a + * non-boolean shorthand with no attached value, so the caller must skip the next token. */ function markChangedShorthandCluster( token: string, @@ -635,21 +418,10 @@ function markChangedShorthandCluster( } /** - * Whether `trimmedArgs` contains a genuine, unconsumed pflag end-of-flags - * sentinel — a bare `--` token that is NOT itself the value a preceding - * value-taking flag already consumed. `--file --` consumes the `--` as - * `--file`'s string value (`pflag@v1.0.10/flag.go:1013-1023`'s - * `parseLongArg`, which grabs the very next token unconditionally); pflag's - * sentinel check only ever inspects the CURRENT token being parsed, never - * one already claimed as a preceding flag's value, so a consumed `--` - * never disables later flag completion. A naive `trimmedArgs.includes("--")` - * treats that consumed token as a terminator too, wrongly shutting off - * flag-name/flag-value completion for the rest of the request: `db dump - * --file -- --s` still offers `--schema`, not zero candidates, while `db - * dump -- --s` — no preceding value flag to consume the `--` — correctly - * returns zero candidates. Walks the same long/short consumption rules - * `collectChangedFlagNames` does, reusing `resolveShortFlagCluster` for - * the short-flag case. + * Whether `trimmedArgs` contains a genuine, unconsumed `--` terminator — one that isn't + * itself the value a preceding value-taking flag already consumed (e.g. `--file --`). A + * naive `trimmedArgs.includes("--")` would treat that consumed token as a terminator too, + * wrongly shutting off flag completion for the rest of the request. */ function hasUnconsumedFlagTerminator( trimmedArgs: ReadonlyArray, @@ -683,19 +455,9 @@ function hasUnconsumedFlagTerminator( } /** - * `--jobs` (`functions deploy`, `storage cp`) and `--last` (`migration down`, - * `db reset`) are validated as `strconv.ParseUint(s, 0, 64)` values, which - * reject a leading `-`/`+` outright — unlike this TS tree's plain signed - * `Flag.integer("jobs"/"last")`. `isValidFlagValue` checks this table - * BEFORE dispatching on `primitiveTag`, since it must catch `storage cp - * --jobs` too, which is `Flag.string("jobs")` in TS (its own handler already - * calls `parseUintBase0` directly at parse time, `cp.command.ts`) - * rather than `Flag.integer` — a bare `primitiveTag` switch would never see - * it: `functions deploy --jobs -1 --p`, `migration down --last -1 --d`, `db - * reset --last -1 --d`, and `storage cp --jobs -1 --r` all return zero - * candidates with the Default directive. Key = `:`, matching `COMPLETION_REQUIRED_FLAGS`'s - * convention. + * Flags whose real validation rejects a leading `-`/`+`, unlike this tree's plain signed + * `Flag.integer`/`Flag.string` declarations — checked before `primitiveTag` dispatch since + * `storage cp --jobs` is declared as `Flag.string`. Key = `:`. */ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ "functions deploy:jobs", @@ -705,28 +467,18 @@ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ ]); /** - * `--query-timeout` (`gen types`) and `--valid-for` (`gen bearer-jwt`) are - * validated as `time.ParseDuration` values, unlike this TS tree's plain - * `Flag.string("query-timeout"/"valid-for")` — same shape as - * `COMPLETION_UINT_FLAGS` above, keyed the same way: `gen types - * --query-timeout bogus --l` and `gen bearer-jwt --role anon --valid-for - * bogus --p` both return zero candidates with the Default directive. + * Flags validated against Go duration syntax (see `isValidGoDuration`) rather than this + * tree's plain `Flag.string` declarations. */ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ "gen types:query-timeout", "gen bearer-jwt:valid-for", ]); -/** - * `--exp` (`gen bearer-jwt`) is validated as a `time.Parse(time.RFC3339, s)` - * value, unlike this TS tree's plain `Flag.string("exp")`: `gen bearer-jwt - * --role anon --exp bogus --p` returns zero candidates with the Default - * directive, while `--exp 2024-01-02T15:04:05Z --p` still offers - * `--profile`/`--payload`. - */ +/** `--exp` (`gen bearer-jwt`) is validated as an RFC 3339 timestamp, not a plain string. */ const COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); -/** Nanosecond scale for each unit `time.ParseDuration`'s `unitMap` accepts (`time/format.go`). */ +/** Nanosecond scale for each unit the Go duration grammar accepts. */ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["ns", 1n], ["us", 1_000n], @@ -738,9 +490,8 @@ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["h", 3_600_000_000_000n], ]); -// `time.ParseDuration` accumulates into a `uint64` and range-checks against -// `1<<63` mid-parse, only narrowing to the `int64` max (`1<<63 - 1`) in the -// final non-negative check — see `isValidGoDuration` below. +// Go's duration grammar accumulates into a `uint64` (range-checked against `1<<63` +// mid-parse) and narrows to the `int64` max in the final non-negative check below. const GO_DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; const GO_DURATION_MAX_INT64 = (1n << 63n) - 1n; @@ -749,36 +500,11 @@ function isAsciiDigit(char: string | undefined): boolean { } /** - * Faithful port of `time.ParseDuration` (`time/format.go`) — the exact - * parser pflag runs for a `DurationVar` flag's `Set` — as a syntax-and-range - * VERDICT (completion only needs a boolean, not the parsed `Duration`, - * mirroring `isValidBase0Int64`'s own shape). An optional sign, then - * either the literal `0` alone, or one or more `[.]` terms - * concatenated (`1h30m`, `1.5h`, `.5s`), accumulating nanoseconds: `BigInt` - * for the integer/fraction accumulators (`uint64` semantics, mirrored - * exactly — no JS `Number` precision loss), and a plain `Number` - * multiply-then-`Math.trunc` for the one step done in `float64` - * (`uint64(float64(f) * (float64(unit) / scale))`) — since a JS `number` IS - * an IEEE-754 double, `Number(bigIntValue)` round-trips through the exact - * same conversion `float64(f)` does, so this step is bit-for-bit identical, - * not merely an approximation of it. - * - * This replaces an earlier regex-only grammar check whose own doc comment - * dismissed the overflow bound as "unreachable through any realistic - * completion input" — disputed and disproven on review: the `int64` - * nanosecond range caps out at ~292 years, so `--query-timeout 2562048h` - * (one hour past the real max, a plausible fat-fingered value, not a - * contrived ~20-digit magnitude) is exactly the kind of input real - * completion traffic can produce, and the old check silently accepted it. - * `gen types --query-timeout 2562048h --l` returns zero candidates with the - * Default directive, matching `bogus`. Cross-checked this implementation - * against go1.26 `time.ParseDuration` across sign/fraction/multi-term/overflow - * edge cases, including the exact `int64` boundary - * (`2562047h47m16.854775807s` → valid, one ns more → invalid) and its - * negative-side asymmetry (`-2562047h47m16.854775808s`, `int64` min, valid — - * one ns more still invalid) — same two's-complement asymmetry - * `isValidBase0Int64` already encodes for - * `MAX_INT64_NEGATIVE_MAGNITUDE`. + * Validates the Go duration syntax (`1h30m`, `1.5h`, `.5s`) these flags accept, returning a + * boolean verdict rather than a parsed value. Uses `BigInt` for the integer/fraction + * accumulators to match Go's `uint64` overflow semantics exactly, and a plain `Number` for + * the one step Go performs in `float64` — a JS `number` is itself an IEEE-754 double, so + * this round-trips bit-for-bit rather than merely approximating it. */ function isValidGoDuration(value: string): boolean { let rest = value; @@ -794,8 +520,7 @@ function isValidGoDuration(value: string): boolean { while (rest.length > 0) { if (!(rest[0] === "." || isAsciiDigit(rest[0]))) return false; - // `leadingInt`: digits before the decimal point. Go returns an error - // immediately on overflow rather than continuing to consume digits. + // Digits before the decimal point; overflow fails immediately without consuming more. let i = 0; let intPart = 0n; while (isAsciiDigit(rest[i])) { @@ -807,8 +532,7 @@ function isValidGoDuration(value: string): boolean { const hasIntDigits = i > 0; rest = rest.slice(i); - // `leadingFraction`: digits after `.`. Go does NOT error on overflow - // here — it just stops accumulating precision and keeps consuming. + // Digits after `.`; overflow here stops accumulating precision but keeps consuming. let fracPart = 0n; let scale = 1n; let hasFracDigits = false; @@ -856,38 +580,17 @@ function isValidGoDuration(value: string): boolean { if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; } - // The final range check only runs for the non-negative case — the - // negative side already got the one-larger `1<<63` bound above, matching - // `int64`'s two's-complement asymmetry (see the doc comment). + // The negative side already got the larger `1<<63` bound above (int64's two's-complement + // asymmetry); only the non-negative case needs this final check. return negative || total <= GO_DURATION_MAX_INT64; } /** - * Mirrors `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` - * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` - * separator, an optional fractional-seconds run of any length introduced by - * EITHER `.` or `,` (the `time` package accepts both spellings of the - * decimal mark per RFC 3339/ISO 8601: `"2024-01-02T15:04:05,5Z"` parses - * identically to `"...05.5Z"`; a bare separator with no following digit, - * e.g. `",Z"`, still fails, and mixing both separators in one timestamp, - * e.g. `".5,5Z"`, still fails too), and a `Z` or `±HH:MM` offset. - * Hour/minute/second are bounded to `0-23`/`0-59`/`0-59` (rejects `":60"` - * — no leap-second allowance — and `"25:"`). The offset's own hour/minute - * fields are ALSO bounded, but not to that same 0-23/0-59 range: the - * zone-offset parser independently caps the offset hour at 24 (not 23) and - * the offset minute at 60 (not 59), each checked in isolation rather than as - * a combined "total offset <= 24h": `"+24:00"`, `"+23:59"`, `"+24:60"`, and - * `"+00:60"` all parse successfully; `"+25:00"` and `"+00:61"` both fail - * with "time zone offset hour/minute out of range". Month/day validity - * (including leap years, and short months like April's 30 days) is checked - * by round-tripping the parsed year/month/day through `Date#setUTCFullYear` - * and comparing what comes back — that method (unlike the `Date` - * constructor or `Date.UTC`) does NOT special-case a 0-99 year into - * 1900+year, so it stays correct for the accepted `"0000-01-02T15:04:05Z"`, - * and its normal calendar-overflow behavior (Feb 29 rolling to Mar 1 in a - * non-leap year, day 32 rolling into the next month, month 13 rolling into - * the next year) exactly reproduces the same leap-year and day/month-bounds - * rejections without hand-rolling the calendar math. + * Matches RFC 3339 (`2006-01-02T15:04:05Z07:00`): fractional seconds may use either `.` or + * `,`, and the offset's own hour/minute are independently capped at 24/60 (not the 0-23/0-59 + * bounds used for the time fields themselves). Calendar validity is checked by round-tripping + * through `Date#setUTCFullYear`, which — unlike the `Date` constructor — doesn't special-case + * a 0-99 year into 1900+year. */ const GO_RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; @@ -913,27 +616,16 @@ function isValidGoRfc3339(value: string): boolean { } /** - * Go registers `--sql-paths` (`db reset`, `cmd/db.go:714`) as a plain - * `StringArrayVar` — pflag stores each repeated occurrence verbatim, with NO - * CSV parsing — unlike every OTHER variadic (`isVariadic`) string flag - * reachable from this tree, which Go declares `StringSliceVar`/ - * `StringSliceVarP` (CSV-split per occurrence): `--domains` (sso - * add/update), `--schema`/`--exclude` (db dump/diff/pull/lint, gen types, db - * schema declarative generate/sync), `--config` (postgres-config - * delete/update), `--db-unban-ip` (network-bans remove), `--db-allow-cidr` - * (network-restrictions update), and `--exclude`/`--override-name` - * (start/status). This is the one, small exception — kept as an exclusion - * set rather than an inclusion table, since the inclusion side is the much - * longer list. Key = `:`. + * `db reset`'s `--sql-paths` stores each repeated occurrence verbatim, unlike every other + * variadic string flag in this tree, which CSV-splits each occurrence. Kept as a small + * exclusion set rather than an inclusion table, since the inclusion side is much longer. + * Key = `:`. */ const COMPLETION_NON_CSV_VARIADIC_FLAGS: ReadonlySet = new Set(["db reset:sql-paths"]); /** - * Validates a CSV-per-occurrence (`isVariadic`, pflag `StringSliceVar`) - * flag's value the same way `parseStringSliceFlag` does at real parse - * time — reused here directly rather than re-implemented, so the two never - * drift: `sso add --domains 'a,"b' --type` — an unterminated quote — returns - * zero candidates with the Default directive, not `--type`. + * Validates a CSV-per-occurrence variadic flag's value with `parseStringSliceFlag`, reused + * directly so the two never drift. */ function isValidCsvFlagValue(value: string): boolean { try { @@ -945,16 +637,10 @@ function isValidCsvFlagValue(value: string): boolean { } /** - * `--output`/`-o` is a command-scoped enum: the root persistent flag - * accepts `env|pretty|json|toml|yaml` while `db query`'s own local flag - * accepts `json|table|csv` — two value sets that only overlap on `json`, on - * what this TS tree models as a single global `OutputFlag` whose - * `choiceKeys` is the union of both (`go-output-flag.ts`), so - * `flag.choiceKeys` alone can't tell which enum applies at the resolved - * command. This restores per-command validation: `--output table ""` - * outside `db query`, and `db query --output env ""`, are BOTH rejected - * with zero candidates and the Default directive, even though each value is - * accepted on the OTHER side. + * `--output` is modeled as one global flag whose `choiceKeys` is the union of two distinct + * enums (`env|pretty|json|toml|yaml` everywhere else, `json|table|csv` for `db query`), so + * `flag.choiceKeys` alone can't tell which applies at the resolved command. This restores + * per-command validation. */ function outputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray { return matchedPath.length === 2 && matchedPath[0] === "db" && matchedPath[1] === "query" @@ -963,22 +649,11 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray } /** - * Validates a flag's value the way pflag's typed `Value.Set` does inside - * `finalCmd.ParseFlags()` — e.g. `-o not-a-format` (a `Choice`-typed - * `--output`) or `--debug=maybe` (a `Boolean`-typed `--debug`) fail to parse - * in real pflag, and cobra reports the parse error instead of generating any - * completions: both return zero candidates with the Default directive, - * exactly like an unresolved flag name. The command-dependent overrides - * (`COMPLETION_UINT_FLAGS`, `COMPLETION_DURATION_FLAGS`, - * `COMPLETION_RFC3339_FLAGS`, `COMPLETION_NON_CSV_VARIADIC_FLAGS`) - * are checked BEFORE the `primitiveTag` dispatch — a bare `FlagDescriptor` - * can't express any of them on its own (all need `matchedPath`, and the uint - * one specifically needs to catch a flag whose TS `primitiveTag` isn't - * `"Integer"` at all, e.g. `storage cp --jobs`; the duration/RFC3339 ones - * catch flags that are plain `Flag.string` in TS but a `Duration`/`Time` - * pflag value). Every other primitive shape pflag can actually reject is - * checked in the switch; `String`/`Path`/`Date`/etc. flags accept any string - * there too, so the default case is unconditionally valid. + * Validates a flag's value; an invalid value returns zero candidates with the Default + * directive, exactly like an unresolved flag name. The command-dependent overrides + * (`COMPLETION_UINT_FLAGS`, `COMPLETION_DURATION_FLAGS`, `COMPLETION_RFC3339_FLAGS`, + * `COMPLETION_NON_CSV_VARIADIC_FLAGS`) are checked before the `primitiveTag` dispatch, since + * they key on `matchedPath`, which a bare `FlagDescriptor` can't express. */ function isValidFlagValue( matchedPath: ReadonlyArray, @@ -1020,27 +695,12 @@ function isValidFlagValue( } /** - * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`) the same way - * pflag's `parseSingleShortArg` does (`pflag@v1.0.10/flag.go:1040-1114`) — - * first character owns the value, not last. This is deliberately a - * DIFFERENT algorithm from `resolveFlagFromToken`'s last-character - * resolution: that function mirrors cobra's OWN separate, narrower - * `checkIfFlagCompletion` heuristic, used only to guess "is the CURRENT or - * PRECEDING token mid-way through being value-completed" — not to strictly - * parse a token that's already fully typed. This function mirrors the real - * strict parser (`finalCmd.ParseFlags()`) instead, used by - * `findUnresolvedFlagToken`: `functions deploy -j4 --p` still offers - * `--profile`/`--project-ref`/`--prune` — `-j4` is a fully valid, already- - * resolved `--jobs=4`, not an unknown flag. - * - * Returns `undefined` if any character in the cluster doesn't resolve to an - * in-scope flag shorthand. Otherwise returns the flag that ultimately owns - * the cluster's (possibly absent) attached value — the first non-boolean - * shorthand encountered, or the cluster's last shorthand if every character - * in it is boolean — plus that attached value, which is `undefined` only - * when there is nothing left in the token to attach (`-o` alone, or an - * all-boolean cluster like `-rf`), meaning a following token supplies it - * instead. + * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`), where the first non-boolean + * character owns the value, not the last — unlike `resolveFlagFromToken`'s completion guess, + * this strictly parses a complete token, so `-j4` resolves to `--jobs=4` rather than an + * unknown flag. Returns `undefined` if any character fails to resolve; the returned + * `attachedValue` is `undefined` only when nothing is left to attach, meaning a following + * token supplies it instead. */ function resolveShortFlagCluster( token: string, @@ -1067,66 +727,11 @@ function resolveShortFlagCluster( } /** - * Finds the first token in `trimmedArgs` that either (a) looks like a flag - * (starts with `-`, excluding the bare `-` positional pflag itself treats as - * a non-flag argument) but does not resolve to anything in `inScopeFlags`, - * (b) resolves to a real flag whose value `isValidFlagValue` rejects, - * or (c) resolves to a real, non-boolean flag with NO value available at all - * — no attached suffix and no following token — AND `toComplete` itself is a - * bare flag-shaped token (starts with `-`). - * - * That last condition mirrors a real two-part cobra/pflag interaction: - * cobra's `checkIfFlagCompletion` only rescues a trailing incomplete flag - * from `ParseFlags()` (treating it as "the flag currently being - * value-completed" instead of a parse error) when `toComplete` is EMPTY or - * otherwise not itself flag-shaped (`completions.go:666-687`, the `prevArg` - * branch, which strips the dangling flag out of `finalArgs` before - * `ParseFlags` ever sees it). When `toComplete` IS flag-shaped, that rescue - * never happens — `checkIfFlagCompletion` either returns immediately without - * touching `finalArgs` (no `=`, `completions.go`'s "Normal flag completion" - * early return) or extracts a flag name from `toComplete`'s OWN prefix - * before its `=` (`completions.go`'s `flagWithEqual` branch) — neither path - * strips a DIFFERENT, already-dangling flag earlier in `finalArgs`, so the - * real `finalCmd.ParseFlags()` call (`completions.go:373-375`) fails - * outright on it. Whether `toComplete` itself contains `=` is irrelevant: - * that `=` only ever resolves `toComplete`'s own flag name, never rescues an - * earlier dangling one: `__complete -o --d` returns zero candidates with - * the Default directive — the `ParseFlags` error is "flag needs an - * argument: 'o' in -o" — while `__complete -o ''` and `__complete -o pre` - * both instead fall through to flag-VALUE completion for `--output`, per - * `classifyCompletion`'s Case 2; `__complete sso add --type saml - * --metadata-file --attribute-mapping-file=` ALSO returns zero candidates - * with the Default directive — the `ParseFlags` error is "flag needs an - * argument: --metadata-file" — even though the current token has an `=` and - * identifies a wholly separate flag, not `--metadata-file`'s value. - * - * Long flags (`--foo`, `--foo=bar`) resolve via `resolveFlagFromToken` - * (no first/last-character ambiguity for a `--name` token). Short flags - * resolve via `resolveShortFlagCluster` instead — see that function's - * doc comment for why this deliberately does NOT reuse - * `resolveFlagFromToken`'s last-character heuristic here. - * - * Consumes a following token as a non-boolean flag's value the same way - * `resolveCommandPath` does. A bare `--` ends the scan entirely without - * itself counting as unresolved — pflag's own end-of-flags sentinel, after - * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s - * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). - * Returns the offending token, or `undefined` if every flag-shaped token - * resolves to a real flag with a valid, available value. - * - * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it - * doesn't recognize while walking for a subcommand name (see - * `resolveCommandPath`, which only needs to know "does this consume a - * value", not "is this real"), but the later `finalCmd.ParseFlags()` strictly - * validates every remaining flag token — both that it resolves AND that its - * value parses — against the fully-resolved command's complete flag set, and - * fails outright on the first one that doesn't (`completions.go:373-375`) — a - * failure so early it wins even over the `--help`/`--version` short-circuit - * below: both `__complete --bogus --help ""` and `__complete --help --bogus - * ""` report the unknown flag, not help; a bare `__complete --bogus ""` - * returns zero candidates with the Default directive, not the root - * subcommand list; `__complete -- ""` is unaffected and still lists every - * root subcommand. + * Finds the first token that's flag-shaped but unresolved, resolves to a flag whose value + * fails `isValidFlagValue`, or is a non-boolean flag with no value available (only when + * `toComplete` is itself flag-shaped; otherwise a trailing flag is understood to be + * mid-value-completion). A bare `--` ends the scan without counting as unresolved. This + * check takes priority even over the `--help`/`--version` short-circuit below. */ function findUnresolvedFlagToken( trimmedArgs: ReadonlyArray, @@ -1134,14 +739,8 @@ function findUnresolvedFlagToken( inScopeFlags: ReadonlyArray, matchedPath: ReadonlyArray, ): string | undefined { - // See this function's doc comment: only a `toComplete` that's itself a - // bare flag-shaped token blocks cobra's "rescue" of a trailing, - // value-less flag — whether that token also contains `=` is irrelevant, - // since the `=` only ever resolves `toComplete`'s OWN flag name, never an - // earlier, different dangling flag. Every non-flag-shaped `toComplete` - // leaves the trailing flag for flag-VALUE completion instead, so a - // missing value at the end of `trimmedArgs` is not, by itself, unresolved - // in that case. + // A trailing flag with no value counts as unresolved only when `toComplete` is itself + // flag-shaped; otherwise it's understood to be mid-value-completion instead. const trailingMissingValueIsFatal = toComplete.startsWith("-"); let index = 0; @@ -1174,12 +773,8 @@ function findUnresolvedFlagToken( const cluster = resolveShortFlagCluster(token, inScopeFlags); if (cluster === undefined) return token; - // An attached value (`-o=json`, or a non-boolean's `-ojson`) must be - // validated BEFORE the boolean short-circuit below — pflag treats - // `-f=value` as an explicit value for a boolean shorthand too - // (`pflag@v1.0.10/flag.go:1005-1033`), so a boolean owner does not, on - // its own, mean "nothing to validate": `storage cp -r=maybe --j` returns - // zero candidates with the Default directive, not `--jobs`. + // An attached value (`-o=json`, or a non-boolean's `-ojson`) is validated before the + // boolean short-circuit below, since a boolean shorthand can also take an explicit value. if (cluster.attachedValue !== undefined) { if (!isValidFlagValue(matchedPath, cluster.flag, cluster.attachedValue)) return token; continue; @@ -1212,30 +807,10 @@ function flagValueCompletion( } /** - * Mirrors cobra's auto-registered `help` command's own `ValidArgsFunction` - * (`command.go:1263-1310`, `InitDefaultHelpCmd`): `help` is a REAL subcommand - * of root, and its `ValidArgsFunction` re-resolves everything typed after it - * from root — via `c.Root().Find(args)` — then lists THAT resolved command's - * own visible subcommands, filtered by `toComplete`'s prefix. `help db d` - * therefore completes as if `d` were being completed inside `db` (`diff`, - * `dump`), not as an argument to `help` itself. - * - * Mirrors cobra's `legacyArgs` validator (`args.go:28-37`) for the "unknown - * command" error `Find` surfaces through `e`: it fires ONLY when the - * resolved command is root itself (no real descent happened at all) AND a - * token is left over — a token left over under any OTHER resolved command is - * never an error there (subcommands "will always accept arbitrary - * arguments"). On that error path cobra returns zero candidates, but still - * with the NoFileComp directive, same as the success path: `help bogus d` - * -> no candidates; `help db bogus d` -> still `db`'s subcommands, since - * `db` is not root. - * - * `root` here is always `finalCommand` from the outer resolution: `help` has - * no node anywhere in this TS tree (it is a synthesized candidate, not a - * real `Command.Command.Any` — see the comment where Case 3 pushes it - * below), so the outer `resolveCommandPath` call always stops at root - * immediately when `trimmedArgs[0] === "help"`, making `finalCommand` and - * real cobra's `c.Root()` the same command. + * `help ` completes as if `` were being typed directly, so `help db d` offers + * `db`'s own subcommands (`diff`, `dump`) filtered by `d`. An unresolved leftover positional + * returns no candidates only when nothing beyond `help` resolved at all (e.g. `help bogus`); + * a leftover under any resolved command still lists that command's subcommands. */ function helpArgumentCandidates( root: Command.Command.Any, @@ -1253,8 +828,7 @@ function helpArgumentCandidates( name: sub.name, description: sub.shortDescription ?? sub.description, })); - // Cobra's help command is itself one of root's `Commands()`, so re-resolving - // to root also re-lists `help` (verified empirically: `help h` -> `help`). + // `help` is included when nothing after it resolved, since it's one of root's own subcommands. if (matchedPath.length === 0) { candidates.push({ name: "help", description: "Help about any command" }); } @@ -1267,39 +841,11 @@ function helpArgumentCandidates( } /** - * Classifies a single completion request into candidates + directive, - * mirroring cobra's `checkIfFlagCompletion` and the branch in - * `getCompletions` that follows it: - * - * 0. A flag-shaped token that doesn't resolve to any in-scope flag - * short-circuits to no candidates with the Default directive — mirrors - * `finalCmd.ParseFlags()` failing outright on an unrecognized flag, which - * wins even over `--help`/`--version` below. - * 0.5. An unmatched ROOT-level positional (`matchedPath.length === 0` — no - * real descent happened at all — with a genuine leftover positional token) - * ALSO short-circuits to no candidates with the Default directive, and - * wins over `--help`/`--version` too — mirrors `Command.Find`'s own - * `legacyArgs` validator (`cobra@v1.10.2/args.go:28-37`), which returns - * `unknown command %q` precisely when the resolved command is root, has - * subcommands (root always does), and has a leftover non-flag positional - * — an error `getCompletions` surfaces as zero candidates before doing - * anything else with `finalCmd`. - * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when - * resolved to the root command) short-circuits to no candidates — these - * exit before any real completion runs. - * 2. A genuine, unconsumed bare `--` anywhere in `trimmedArgs` disables ALL - * flag-name and flag-value completion (Cases 3/4 below) for the rest of - * this request — mirrors cobra's `flagCompletion` gate, which goes false - * the moment a previous `--` is already present (`completions.go:364- - * 381`; see `hasUnconsumedFlagTerminator` below for why "unconsumed" - * matters). - * 3. `toComplete` is a bare flag with no `=` → flag-NAME completion. - * 4. `toComplete` (or the immediately preceding token) identifies a - * non-boolean flag's value slot → flag-VALUE completion. - * 5. Otherwise → subcommand-name + required-flag (noun) completion; five - * specific leaf paths (`completion[ bash|zsh|fish|powershell]`) force the - * directive to NoFileComp regardless of what the subcommand walk above - * computed — see `COMPLETION_NO_FILE_COMP_PATHS`. + * Classifies a completion request into candidates + directive, in priority order: an + * unresolved/invalid flag-shaped token or unmatched root positional short-circuits first, + * ahead of `--help`/`--version`; an unconsumed `--` then disables flag-name/flag-value + * completion; otherwise a bare flag with no `=` gets flag-name completion, a flag's value + * slot gets flag-value completion, and everything else gets subcommand-name completion. */ export function classifyCompletion(input: ClassifyCompletionInput): CompletionResult { const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; @@ -1309,57 +855,21 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe return { candidates: [], directive: CompletionDirective.Default }; } - // Mirrors cobra's `stripFlags` (`command.go:674-710`), which the - // `legacyArgs` validator below runs its leftover-count check against — a - // bare `-` (and an empty string) is dropped from consideration, NOT - // counted as a genuine leftover positional, even though - // `resolveCommandPath` deliberately leaves a bare `-` IN - // `leftoverArgs` for other purposes: `__complete - --d` still offers - // root's own `--debug`/`--dns-resolver`, since `stripFlags` drops the lone - // `-` and leaves zero real leftover commands to error on — root cause - // shared with the `nosuch --d` case below. - // - // Exempts `trimmedArgs[0] === "help"`: real cobra's `help` is a REAL child - // node of root (`InitDefaultHelpCmd`), so `Find(["help", ...])` resolves - // INTO the help command itself rather than stopping at root — this TS - // tree has no such node (see `helpArgumentCandidates`'s doc - // comment), so the outer resolution below always sees "help" as an - // immediate non-match and would otherwise misfire this same root-level - // check for every legitimate `help ...` request. Case 3's own - // `isAtRoot && trimmedArgs[0] === "help"` branch re-resolves `help`'s own - // arguments from root separately and already reproduces cobra's real - // unknown-command handling for THAT inner resolution - // (`helpArgumentCandidates`'s `matchedPath.length === 0 && - // leftoverArgs.length > 0` check). + // A bare `-` or empty string doesn't count as a genuine leftover positional, even though + // `resolveCommandPath` keeps it in `leftoverArgs` for other purposes. `help` is exempted + // since `helpArgumentCandidates` handles its own leftover-positional case separately. const rootLeftoverPositionals = leftoverArgs.filter((arg) => arg !== "" && !arg.startsWith("-")); if (isAtRoot && trimmedArgs[0] !== "help" && rootLeftoverPositionals.length > 0) { - // Mirrors cobra's `Command.Find` -> `legacyArgs` (`args.go:28-37`): - // resolving to root itself (no descent at all) with a leftover - // positional is an "unknown command" error there, unlike a leftover - // positional under any OTHER resolved command, which is never an error: - // `nosuch --d` returns zero candidates with the Default directive — - // even ahead of the `--help`/`--version` short-circuit below, i.e. - // `nosuch --help` is ALSO zero candidates, not the help short-circuit's - // NoFileComp — while `db bogus --d`, where `db` itself resolves, still - // offers `db`'s own `--debug`/`--dns-resolver` normally. + // Resolving to root itself with a leftover positional is treated as an unknown command; a + // leftover positional under any other resolved command is not an error. return { candidates: [], directive: CompletionDirective.Default }; } - // `collectChangedFlagNames` (not a raw token scan) is load-bearing here: it - // already stops at a genuine, unconsumed `--` terminator and already skips - // a token consumed as a PRECEDING non-boolean flag's value — exactly the - // two cases pflag's own `Changed` tracking respects and a bare - // `trimmedArgs.some(...)` token scan does not: `db dump -- --help ""` - // still offers `db dump`'s own completions, not the help short-circuit — - // `--help` is positional, past the terminator; `--workdir --version br` - // still completes `branches`, not the version short-circuit — `--version` - // is consumed as `--workdir`'s string value, never parsed as a flag at all. + // `collectChangedFlagNames`, not a raw token scan, correctly treats a positional `--help` + // past a `--` terminator, or one consumed as a preceding flag's value, as not present. const changedFlagNames = collectChangedFlagNames(trimmedArgs, inScopeFlags); - // `version` is gated on `isAtRoot`: cobra's `--version` flag lives on the - // root command only (see `collectInScopeFlags`'s comment) — `help` is - // NOT gated the same way since every command registers its own local - // `--help` (present in `inScopeFlags`/`changedFlagNames` at every depth). + // `--version` only applies at root; `--help` is available at every depth. if (changedFlagNames.has("help") || (isAtRoot && changedFlagNames.has("version"))) { return { candidates: [], directive: CompletionDirective.NoFileComp }; } @@ -1370,13 +880,8 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const toCompleteIsFlag = toComplete.startsWith("-"); const toCompleteEqualsIndex = toComplete.indexOf("="); - // Once a genuine, unconsumed bare `--` sentinel has already appeared, - // cobra never does flag-name or flag-value completion again for the rest - // of the request: `db dump -- --s` returns zero candidates with the - // Default directive, not `--schema`. See - // `hasUnconsumedFlagTerminator`'s doc comment for why a raw - // `trimmedArgs.includes("--")` over-triggers when a preceding value-taking - // flag consumed that `--` as its own value instead. + // Once a genuine `--` terminator has appeared, flag-name/flag-value completion never runs + // again for the rest of the request. const hasFlagTerminator = hasUnconsumedFlagTerminator(trimmedArgs, inScopeFlags); // Case 1: flag-NAME completion. @@ -1384,8 +889,7 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const requiredCandidates = requiredFlags.flatMap((flag) => flagNameCandidates(flag, toComplete), ); - // Once ANY required flag is still unset, ONLY required flags are - // offered — this exactly mirrors cobra. + // If any required flag is still unset, only required flags are offered. if (requiredCandidates.length > 0) { return { candidates: requiredCandidates, directive: CompletionDirective.NoFileComp }; } @@ -1398,14 +902,8 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe // Case 2: flag-VALUE completion. if (!hasFlagTerminator) { if (toCompleteIsFlag) { - // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. - // Cobra's checkIfFlagCompletion treats ANY `--flag=value` token - // (including a boolean's) as flag-value completion — the "reset to - // noun completion for a boolean" only applies in the separate no-`=` - // two-token case handled by the `else` branch below - // (`completions.go`'s `!flagWithEqual` guard around that reset): - // `--debug=maybe` returns zero candidates with the Default directive, - // not the root command list. + // A `--flag=value` token is always treated as flag-value completion, even for a + // boolean flag. const resolved = resolveFlagFromToken( toComplete.slice(0, toCompleteEqualsIndex), inScopeFlags, @@ -1416,35 +914,21 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe if ( precedingToken !== undefined && precedingToken.startsWith("-") && - // A bare `-` is excluded — pflag's `isFlagArg` (`command.go:750-753`) - // requires at least 2 characters, so real cobra's own equivalent - // "preceding token is flag-shaped" check never fires for it either, - // and this must fall through to Case 3 instead of hard-stopping: - // `help db - d` still lists `db`'s subcommands `diff`/`dump`, not - // zero candidates. + // A bare `-` is excluded, so it falls through to Case 3 instead of hard-stopping. precedingToken !== "-" && !precedingToken.includes("=") ) { const resolved = resolveFlagFromToken(precedingToken, inScopeFlags); if (resolved === undefined) { - // Cobra's checkIfFlagCompletion errors out here (a `flagCompError` - // short-circuits `getCompletions` outright) rather than falling - // through to noun completion — an unresolved trailing flag (per - // that function's OWN last-character heuristic, not - // `findUnresolvedFlagToken`'s strict first-character parse) - // before an empty/non-flag toComplete is a hard stop: `-ojson ""` - // returns zero candidates with the Default directive, even though - // `-ojson` is a perfectly valid `-o=json` under real pflag parsing - // — cobra's own heuristic looks at `-ojson`'s LAST character, `n`, - // which resolves to nothing. + // An unresolved trailing flag before an empty/non-flag toComplete is a hard stop, + // not a fall-through to noun completion. return { candidates: [], directive: CompletionDirective.Default }; } if (!resolved.isBoolean) { return flagValueCompletion(matchedPath, resolved.name); } - // A resolved BOOLEAN precedingToken falls through to Case 3 — it - // never consumed a value, so this wasn't really flag-value - // completion. + // A resolved boolean precedingToken falls through to Case 3, since it never + // consumed a value. } } @@ -1452,21 +936,14 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const candidates: Array = []; let directive: number = CompletionDirective.Default; - // `help` is a real cobra subcommand with its own `ValidArgsFunction` that - // completes a SECOND, independent command-path lookup from root — see - // `helpArgumentCandidates`'s doc comment. `isAtRoot && - // trimmedArgs[0] === "help"` exactly identifies "this request is `help - // ...`": `help` isn't a node anywhere in this tree, so the outer - // `resolveCommandPath` call always stops at root immediately when - // it's the first token. + // A `help ...` request is resolved separately, since `help` isn't a real command node + // in this tree. if (isAtRoot && trimmedArgs[0] === "help") { return helpArgumentCandidates(finalCommand, trimmedArgs.slice(1), toComplete); } - // Once any flag or extra positional token has already appeared before this - // position, subcommand-name completion is suppressed entirely (cobra's - // `len(finalArgs) == 0` gate) — including the directive it would otherwise - // set, which stays at `Default` in that case (`completions.go:489,499-522`). + // Any flag or extra positional token before this position suppresses subcommand-name + // completion entirely, leaving the directive at Default. if (leftoverArgs.length === 0) { const visibleSubcommands = flattenSubcommands(finalCommand).filter((sub) => !sub.unlisted); if (visibleSubcommands.length > 0) { @@ -1475,22 +952,13 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe name: sub.name, description: sub.shortDescription ?? sub.description, })); - // Cobra's `InitDefaultHelpCmd` (`command.go:1100,1263-1266`) auto-registers a - // `help` subcommand on whichever command `ExecuteC()` is called against — - // here, always the root — but never recursively on descendants: - // `__complete db ""` does NOT surface it, only `__complete ""` does. - // This TS tree has no explicit `help` command node to walk, so - // synthesize the one candidate cobra would otherwise contribute, - // matching its literal `Short` text. + // `help` isn't a real command node in this tree; synthesize it as a candidate, but + // only at root, not recursively on descendants. if (isAtRoot) { subcommandCandidates.push({ name: "help", description: "Help about any command" }); } - // Cobra's own `Commands()` — what its subcommand-name completion walks — - // sorts alphabetically by name whenever `EnableCommandSorting` (the - // default) is on. This tree's subcommand declarations already happen to - // be listed alphabetically, so this sort is a no-op everywhere except at - // the root, where it places the synthetic "help" entry above in its - // correct alphabetical position. + // Sorted alphabetically so the synthetic "help" entry lands in its correct position + // at root; a no-op everywhere else, since subcommands are already declared in order. subcommandCandidates.sort((a, b) => a.name.localeCompare(b.name)); for (const candidate of subcommandCandidates) { if (candidate.name.startsWith(toComplete)) { @@ -1500,16 +968,13 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe } } - // Unconditional append in cobra — not gated on `leftoverArgs`. + // Required flags are always offered here, regardless of `leftoverArgs`. for (const flag of requiredFlags) { candidates.push(...flagNameCandidates(flag, toComplete)); } - // Cobra always invokes a resolved command's own `ValidArgsFunction` (when - // registered) at the very end of `getCompletions`, and that call - // OVERWRITES whatever directive the subcommand walk above already set - // (`completions.go:564-579`) — see `COMPLETION_NO_FILE_COMP_PATHS`'s - // doc comment for which paths this applies to and why. + // Overrides whatever directive the subcommand walk above set; see + // `COMPLETION_NO_FILE_COMP_PATHS`. if (COMPLETION_NO_FILE_COMP_PATHS.has(matchedPath.join(" "))) { directive = CompletionDirective.NoFileComp; } @@ -1517,16 +982,10 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe return { candidates, directive }; } -/* ========================================================================== */ -/* Orchestration */ -/* ========================================================================== */ - /** - * The pure, deps-free completion algorithm: resolves the command path, - * collects in-scope flags, and classifies the request. Returns `undefined` - * when `argv[0]` isn't a completion request, or when cobra's `args` (i.e. - * `argv.slice(1)`) is empty — mirroring cobra's own `MinimumNArgs(1)` failure - * (see the module doc comment for why that case isn't otherwise reproduced). + * The pure, deps-free completion algorithm: resolves the command path, collects in-scope + * flags, and classifies the request. Returns `undefined` when `argv[0]` isn't a completion + * request, or when there are no args to classify. */ export function respondToComplete( root: Command.Command.Any | undefined, @@ -1555,10 +1014,6 @@ export function respondToComplete( }); } -/* ========================================================================== */ -/* Response formatting */ -/* ========================================================================== */ - const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ "1", "t", @@ -1583,12 +1038,9 @@ function parseGoBool(value: string): boolean | undefined { } /** - * Cobra's real, undocumented-to-users-but-real `getEnvConfig` behavior: - * `argv[0] === "__completeNoDesc"` always wins; otherwise - * `SUPABASE_COMPLETION_DESCRIPTIONS` (program-specific) is checked first, - * falling back to the generic `COBRA_COMPLETION_DESCRIPTIONS` when unset or - * empty. An unparseable value (per `strconv.ParseBool`'s accepted - * spellings) is ignored, leaving the `argv[0]`-derived default in place. + * `argv[0] === "__completeNoDesc"` always wins; otherwise `SUPABASE_COMPLETION_DESCRIPTIONS` + * is checked first, falling back to `COBRA_COMPLETION_DESCRIPTIONS` when unset or empty. An + * unparseable value is ignored, leaving the `argv[0]`-derived default in place. */ export function resolveIncludeDescriptions( argv0: string | undefined, @@ -1609,16 +1061,15 @@ function formatCompletionLine( ): string { if (!includeDescriptions) return candidate.name.trim(); const firstDescriptionLine = (candidate.description ?? "").split("\n")[0] ?? ""; - // `.trim()` on the whole joined string (not just the description) is what - // makes a candidate with no description end up as a bare name with no - // trailing tab, not `"name\t"` — reproduces cobra's exact `TrimSpace` step. + // Trimming the whole joined string, not just the description, drops the trailing tab + // when there's no description. return `${candidate.name}\t${firstDescriptionLine}`.trim(); } /** - * Formats a completion result the way cobra's generated shell scripts expect: - * one line per candidate (`name` or `name\tdescription`), then a final - * `:` line. Every line ends with `\n`. + * Formats a completion result as the shell completion protocol expects: one line per + * candidate (`name` or `name\tdescription`), then a final `:` line, each ending + * with `\n`. */ export function formatCompletionResponse( response: CompletionResult, @@ -1631,41 +1082,12 @@ export function formatCompletionResponse( return lines.map((line) => `${line}\n`).join(""); } -/* ========================================================================== */ -/* Entry point */ -/* ========================================================================== */ - /** - * The `cli_command_executed` capture for a `__complete`/`__completeNoDesc` - * request. This event fires for every resolved command, including cobra's - * hidden `__complete`, regardless of the handler's own outcome — this - * interceptor is the only thing that fires it for a completion request, - * since nothing else in the completion path runs a command handler. - * `command` is always the literal `"__complete"`, never - * `"__completeNoDesc"`: cobra registers `__completeNoDesc` as an ALIAS of - * `__complete` (`completions.go:234`), and the recorded value is derived - * from the command's own primary name, not the alias it was invoked as. - * `output_format` is the fixed literal `"text"`: `__complete` is - * `DisableFlagParsing: true`, so there is no resolved `--output`/`-o` value - * to mirror here. - * - * Deliberately narrower than `withCommandTelemetry` - * (`telemetry/command-telemetry.ts`): that wrapper is - * shaped for a real Effect `Command` handler running inside `runCli`'s full - * runtime (`CommandRuntime`/`Output`/`ProcessControl`/`Stdio`), none of which - * exist here — this interceptor runs before Effect's argv parser, before - * `runCli` ever bootstraps. This also deliberately does NOT reproduce - * profile loading, the workdir change, or the GitHub upgrade check — none of - * that has any bearing on the analytics contract, and real generated - * completion scripts discard this process's stderr outright, so reproducing - * the upgrade message would be a pure regression. Do not "fix" this back - * toward that fuller behavior. - * - * Only requires `Analytics` from context (not the concrete production - * layer) so tests can provide `mockAnalytics()` directly instead of the - * real, consent-gated `analyticsLayer` — see - * `captureCompleteTelemetry` below for the production wiring, and - * `complete.integration.test.ts` for the test double usage. + * Fires the `cli_command_executed` telemetry capture for a `__complete`/`__completeNoDesc` + * request — the only thing that captures it, since no command handler runs on this path. + * `command` is always `"__complete"` (never the alias); `output_format` is fixed to `"text"` + * since `__complete` disables flag parsing. Intentionally skips profile/workdir/upgrade + * bootstrapping, since none of it affects analytics and scripts discard this process's stderr. */ export function captureCompleteTelemetryEffect( exitCode: number, @@ -1689,27 +1111,19 @@ export function captureCompleteTelemetryEffect( const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; -// `analyticsLayer` on its own still needs `CliSettings | RuntimeInfo | Tty` -// (via the `telemetryRuntimeLayer` it folds in) on top of the `FileSystem`/ -// `Path` platform layer — `shared/cli/run.ts` normally supplies those as part -// of its own much larger composed tree. `standaloneAnalyticsConfigLayer` -// packages the same small set for a caller running outside that tree. +// `analyticsLayer` needs `CliSettings`/`RuntimeInfo`/`Tty` plus a platform layer; +// `standaloneAnalyticsConfigLayer` packages that small set for a caller outside the full +// CLI runtime tree. const completeAnalyticsLayer = analyticsLayer.pipe( Layer.provide(standaloneAnalyticsConfigLayer), Layer.provide(BunServices.layer), ); /** - * Production default for `CompleteDeps.captureTelemetry`: runs - * `captureCompleteTelemetryEffect` against the real, consent-gated - * `analyticsLayer`. - * - * Best-effort and bounded: a missing consent, network hiccup, or DNS failure - * must never hang or fail a user's tab press. `deps.exit` (see - * `tryComplete` below) ultimately calls `process.exit`, which kills the - * process immediately without waiting for pending async work — callers must - * `await` this BEFORE exiting, or the capture will very likely never reach - * PostHog. + * Production default for `CompleteDeps.captureTelemetry`. Best-effort and bounded: a missing + * consent, network hiccup, or DNS failure must never hang or fail a user's tab press. Callers + * must `await` this before exiting, since `process.exit` kills the process without waiting + * for pending async work. */ function captureCompleteTelemetry(exitCode: number, durationMs: number): Promise { return Effect.runPromise( @@ -1722,15 +1136,10 @@ function captureCompleteTelemetry(exitCode: number, durationMs: number): Promise } /** - * Entry-point interceptor with the same shape/contract as the old - * `tryCompletePassthrough`: runs before Effect's CLI argv parser, returns - * `false` immediately (no side effects) when `deps.argv[0]` isn't a - * completion request, otherwise fully handles it and returns `true`. Async - * only because of `deps.captureTelemetry` above — every other helper in this - * file (`respondToComplete` and everything it calls) stays pure and - * synchronous; awaiting the capture here, before `deps.exit(...)`, is what - * lets it actually reach PostHog (see `captureCompleteTelemetry`'s doc - * comment). + * Runs before Effect's CLI argv parser, returning `false` immediately when `deps.argv[0]` + * isn't a completion request, otherwise fully handling it and returning `true`. Async only + * because it awaits `deps.captureTelemetry` before `deps.exit(...)`, so the capture actually + * reaches PostHog before the process exits. */ export async function tryComplete(deps: CompleteDeps): Promise { if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 11a4b7efc4..92dd1bf716 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -37,17 +37,11 @@ describe("respondToComplete", () => { }); it("returns no candidates and the Default directive for a leaf command with no subcommands and no unset required flags", () => { - // `migration list` has no subcommands; `--db-url`/`--password` are optional, - // `--linked` defaults to true, and `--local` is a plain boolean, so nothing - // is left "required" once resolved (verified against migration/list/list.command.ts). const result = respondToComplete(rootCommand, ["__complete", "migration", "list", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("offers a global flag declared once at rootCommand from a nested command path", () => { - // `--debug` is declared exactly once, via GLOBAL_FLAGS -> - // Command.withGlobalFlags on rootCommand (command-internal/global-flags.ts, - // cli/root.ts) — it must still resolve from a resolved subcommand path. const result = respondToComplete(rootCommand, ["__complete", "branches", "--d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("--debug"); @@ -63,7 +57,6 @@ describe("respondToComplete", () => { const offered = respondToComplete(rootCommand, ["__complete", "--wiz"]); expect(offered?.candidates.map((c) => c.name)).toContain("--wizard"); - // A boolean built-in must not poison the line or consume the next token. const after = respondToComplete(rootCommand, ["__complete", "--wizard", ""]); expect(after?.directive).toBe(CompletionDirective.NoFileComp); expect(after?.candidates.map((c) => c.name)).toContain("branches"); @@ -77,7 +70,6 @@ describe("respondToComplete", () => { expect(after?.directive).toBe(CompletionDirective.NoFileComp); expect(after?.candidates.map((c) => c.name)).toContain("branches"); - // An invalid shell value still poisons the line, matching the real parse. const invalid = respondToComplete(rootCommand, [ "__complete", "--completions", @@ -88,9 +80,6 @@ describe("respondToComplete", () => { }); it("offers an ancestor's shared flag (Command.withSharedFlags) from a resolved leaf command", () => { - // `--no-cache` is declared once on the `db schema declarative` group via - // Command.withSharedFlags (declarative.shared.ts) and must be visible from - // its `generate` leaf. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -103,9 +92,6 @@ describe("respondToComplete", () => { }); it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { - // seed.command.ts declares --linked/--local as scoped global flags via - // Command.withGlobalFlags on the `seed` group itself, not at rootCommand - // — a collector that only reads root.globalFlags misses these entirely. const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--l"]); expect(atGroup?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--linked", "--local"]), @@ -118,11 +104,6 @@ describe("respondToComplete", () => { }); it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { - // db diff declares its own local `output`/`-o` (a file path), shadowing - // the global `--output`/`-o` choice flag declared at root — pflag's - // InheritedFlags() skips a persistent flag shadowed by a same-named local - // one, so exactly one `--output` candidate (the local one) must appear, - // not two with contradictory descriptions. const result = respondToComplete(rootCommand, ["__complete", "db", "diff", "--o"]); const outputCandidates = result?.candidates.filter((c) => c.name === "--output"); expect(outputCandidates).toHaveLength(1); @@ -133,28 +114,18 @@ describe("respondToComplete", () => { describe("subcommand completion is not blocked by a preceding global flag", () => { it("lists root subcommands after a bare global flag with no value", () => { - // `--debug ""` used to return zero candidates entirely: the leftover-args - // computation counted `--debug` itself as "positional leftover," gating - // out subcommand-name completion the way cobra never does for a - // persistent flag: `__complete --debug ''` lists all 36 root commands. const result = respondToComplete(rootCommand, ["__complete", "--debug", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); it("lists subcommands after a value-taking global flag and its value", () => { - // `-o json ""` — "json" is `-o`'s consumed value, not a genuine extra - // positional argument, so it must not count as leftover either. const result = respondToComplete(rootCommand, ["__complete", "-o", "json", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("migration"); }); it("lists subcommands after the built-in --log-level and its value", () => { - // `--log-level error ""` used to return zero candidates: the built-in - // never entered the in-scope flag set, so the strict flag walk treated - // it like an unknown flag and poisoned the line (issue #6482). An - // invalid value must still poison it, matching the real parse. const result = respondToComplete(rootCommand, ["__complete", "--log-level", "error", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("sso"); @@ -171,9 +142,6 @@ describe("respondToComplete", () => { }); it("does not offer --version on any command other than the root", () => { - // Cobra's InitDefaultVersionFlag registers --version non-persistently on - // the root command only — it is never inherited by subcommands the way - // --help is. const atRoot = respondToComplete(rootCommand, ["__complete", "--v"]); expect(atRoot?.candidates.map((c) => c.name)).toContain("--version"); @@ -182,11 +150,6 @@ describe("respondToComplete", () => { }); it("returns Default (not NoFileComp) when the resolved command doesn't match a real subcommand", () => { - // Mirrors cobra: the NoFileComp directive is only set INSIDE the - // `len(finalArgs) == 0` gate, alongside the subcommand loop — a bogus - // trailing token (which becomes non-empty leftover) must leave the - // directive at Default, not force NoFileComp just because `db` itself has - // subcommands: `__complete db bogus ''` → `:0`. const result = respondToComplete(rootCommand, ["__complete", "db", "bogus", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -209,11 +172,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on a subcommand's own local --version flag away from the root", () => { - // migration/squash/squash.command.ts declares its own `--version` - // (a target migration version string) — unrelated to cobra's built-in - // root-only version flag. Typing it while completing a *different* - // flag on the same command must behave like normal flag-name - // completion, not trip the root-only help/version short-circuit. const result = respondToComplete(rootCommand, [ "__complete", "migration", @@ -226,11 +184,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { - // A raw token scan for the literal string "--help" over-triggers once - // `--help` appears anywhere, even past an unconsumed `--` sentinel, - // where pflag never parses it as a flag at all: `db dump -- --help ""` - // returns zero candidates with the DEFAULT directive — `db dump`'s own - // file completion — not the help short-circuit's NoFileComp. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -243,11 +196,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on --version consumed as a PRECEDING flag's own string value", () => { - // `--workdir` is a value-taking global flag; pflag consumes the very - // next token as its value regardless of what that token looks like, - // so `--version` here is `--workdir`'s value, never parsed as a flag - // occurrence: `--workdir --version br` still completes `branches`, - // not the version short-circuit. const result = respondToComplete(rootCommand, ["__complete", "--workdir", "--version", "br"]); expect(result?.candidates.map((c) => c.name)).toContain("branches"); expect(result?.directive).toBe(CompletionDirective.NoFileComp); @@ -256,14 +204,6 @@ describe("respondToComplete", () => { describe("unmatched root-level command (CLI-1965 review)", () => { it("returns Default with zero candidates for a flag typed after an unmatched ROOT-level positional", () => { - // Mirrors cobra's Command.Find -> legacyArgs (args.go:28-37): resolving - // to root itself (no descent at all) with a leftover positional is an - // "unknown command" error there, wins even over the --help/--version - // short-circuit, and is stricter than the same situation under any - // OTHER resolved command: `nosuch --d` and `nosuch --help` both return - // zero candidates with the Default directive, while `db bogus --d` — - // `db` itself resolves — still offers `db`'s own --debug/--dns-resolver - // normally. const unknownRoot = respondToComplete(rootCommand, ["__complete", "nosuch", "--d"]); expect(unknownRoot).toEqual({ candidates: [], directive: CompletionDirective.Default }); @@ -289,11 +229,6 @@ describe("respondToComplete", () => { }); it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { - // cobra's own stripFlags (command.go:674-710) — which legacyArgs' error - // check runs against — drops a lone `-` from its leftover count - // entirely, unlike resolveCommandPath's own leftoverArgs (which - // deliberately keeps it for other purposes): `__complete - --d` still - // offers root's own --debug/--dns-resolver. const result = respondToComplete(rootCommand, ["__complete", "-", "--d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--debug", "--dns-resolver"]), @@ -301,11 +236,6 @@ describe("respondToComplete", () => { }); it("does not apply the unmatched-root check to a genuine `help ...` request", () => { - // `help` is not a real node in this tree (see - // helpArgumentCandidates's doc comment), so the outer resolution - // always sees it as an immediate non-match at root — this must not be - // mistaken for cobra's real "unknown command" error, which never fires - // for `help` since real cobra's help command IS a real child of root. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["diff", "dump"]), @@ -315,10 +245,6 @@ describe("respondToComplete", () => { describe("required-flag short-circuit", () => { it("offers exactly the one required flag and nothing else for a command with a single required flag", () => { - // domains/create/create.command.ts: `customHostname: Flag.string("custom-hostname")` - // has no `.pipe(Flag.optional)`/`.pipe(Flag.withDefault(...))`, so it genuinely - // fails to parse when omitted — `projectRef` is optional and - // `includeRawOutput` is boolean, so neither is offered alongside it. const result = respondToComplete(rootCommand, ["__complete", "domains", "create", ""]); expect(result).toEqual({ candidates: [ @@ -332,16 +258,8 @@ describe("respondToComplete", () => { }); it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { - // vanity-subdomains/activate/activate.command.ts wraps `desiredSubdomain` - // in `.pipe(Flag.optional)` on purpose (presence is enforced later, in - // the handler, to let the --experimental gate and login check run - // first) — but cobra's completion-time required-flag annotation - // (`MarkFlagRequired`, `cmd/vanitySubdomains.go:67`) is independent of - // TS's parse-time validation ordering, so completion must still offer - // exactly this flag, matching real cobra: structural inference from - // `Flag.optional` is not a faithful proxy for "does cobra mark it - // required" — `COMPLETION_REQUIRED_FLAGS` is the explicit table - // that fixes this. + // Required-ness comes from the explicit `COMPLETION_REQUIRED_FLAGS` table, not from + // inferring it off `Flag.optional`. const result = respondToComplete(rootCommand, [ "__complete", "vanity-subdomains", @@ -360,14 +278,6 @@ describe("respondToComplete", () => { }); it("does not treat a zero-minimum variadic flag (Flag.atLeast(0)) as required", () => { - // sso/add/add.command.ts: `domains: ssoAddDomainsFlag` builds on - // `stringSliceFlag`, which is `Flag.string(...).pipe(..., Flag.atLeast(0))` - // — a `Variadic` param with `min: 0`. Go never calls - // `MarkFlagRequired("domains")` (only `type`), so `--domains` must not be - // force-offered here — only the genuinely required `--type`/`-t` should - // appear. (Required-ness now comes from the explicit - // `COMPLETION_REQUIRED_FLAGS` table, not structural inference, but - // this scenario is worth keeping as its own regression case.) const result = respondToComplete(rootCommand, ["__complete", "sso", "add", ""]); expect(result?.candidates.map((c) => c.name)).not.toContain("--domains"); expect(result).toEqual({ @@ -389,10 +299,8 @@ describe("respondToComplete", () => { ])( "restricts $flag on sso $command to the $extension file extension", ({ command, flag, extension }) => { - // sso/add/add.command.ts and sso/update/update.command.ts both declare - // --metadata-file/--attribute-mapping-file; cobra's MarkFlagFilename - // calls for these are captured as a small lookup table - // (COMPLETION_FLAG_FILE_EXTENSIONS) rather than derived generically. + // File extensions come from the COMPLETION_FLAG_FILE_EXTENSIONS lookup table, + // not derived generically. const result = respondToComplete(rootCommand, ["__complete", "sso", command, `--${flag}=`]); expect(result).toEqual({ candidates: [{ name: extension, description: undefined }], @@ -402,18 +310,11 @@ describe("respondToComplete", () => { ); it("never completes a choice flag's value (sso add --type )", () => { - // sso/add/add.command.ts: `type: Flag.choice("type", ["saml"])` has no - // registered ValidArgsFunction equivalent in Go, so its value slot must - // resolve to empty candidates + Default, not an enumeration of "saml". const result = respondToComplete(rootCommand, ["__complete", "sso", "add", "--type", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("does not treat a boolean flag as consuming a following value", () => { - // sso/add/add.command.ts: `skipUrlValidation: Flag.boolean("skip-url-validation")`. - // A boolean flag must fall through to Case 3 (bare noun completion) with the - // unchanged toComplete/trimmedArgs, so this must equal the bare `sso add ""` - // response exactly rather than resolve to some flag-value result. const withBooleanFlag = respondToComplete(rootCommand, [ "__complete", "sso", @@ -429,8 +330,6 @@ describe("respondToComplete", () => { describe("changed-flag exclusion and the variadic exception", () => { it("excludes an already-supplied, non-repeatable flag's own name from further completion", () => { - // sso/add/add.command.ts: `metadataUrl: Flag.string("metadata-url")` is not - // variadic, so once supplied it must not be offered again. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -445,9 +344,6 @@ describe("respondToComplete", () => { }); it("keeps offering a variadic flag's own name even after it has already been supplied", () => { - // sso/add/add.command.ts: `domains: ssoAddDomainsFlag` is built on - // `Flag.atLeast(0)` (repeatable) — cobra's real doCompleteFlags keeps a - // Slice/Array-typed flag in the completion list even once `flag.Changed`. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -462,21 +358,11 @@ describe("respondToComplete", () => { describe("flag terminator (`--`) disables flag completion (CLI-1965 review)", () => { it("does not offer a flag-name candidate for a positional operand after `--`", () => { - // Cobra's flagCompletion gate goes false once a `--` is already present - // in the args (completions.go:364-381), so a positional operand that - // happens to start with `-` after the terminator must not be treated as - // a flag-name completion: `db dump -- --s` returns zero candidates with - // the Default directive, not `--schema`. const result = respondToComplete(rootCommand, ["__complete", "db", "dump", "--", "--s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still offers a required flag that appears (as a positional) after `--`", () => { - // completeRequireFlags is called unconditionally in cobra's noun- - // completion branch, even past the terminator — and a token past `--` - // is never parsed as a flag at all, so it must not be marked "changed" - // either: `sso add -- --type --typ` still offers `--type` with the - // Default directive. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -494,11 +380,6 @@ describe("respondToComplete", () => { describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { - // pflag's parseSingleShortArg resolves a shorthand cluster's value by - // its FIRST character, not its last — `-j4` is a fully valid, - // already-resolved `--jobs=4`, so it must not suppress completion of a - // later flag name: `functions deploy -j4 --p` still offers - // --profile/--project-ref/--prune. const result = respondToComplete(rootCommand, [ "__complete", "functions", @@ -515,12 +396,6 @@ describe("respondToComplete", () => { describe("a trailing incomplete flag is a hard parse error only when toComplete is itself flag-shaped (CLI-1965 review)", () => { it("rejects a dangling value-taking flag when toComplete is a bare flag-shaped token", () => { - // Cobra's checkIfFlagCompletion only rescues a trailing incomplete flag - // from ParseFlags() when toComplete is empty or not flag-shaped - // (completions.go:666-687); `-o --d` leaves `-o` dangling with no - // rescue, so the real ParseFlags() call fails outright: zero - // candidates with the Default directive — the error is "flag needs an - // argument: 'o' in -o". const result = respondToComplete(rootCommand, ["__complete", "-o", "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -537,14 +412,6 @@ describe("respondToComplete", () => { ); it("rejects a dangling value-taking flag even when toComplete is a DIFFERENT flag's attached-value token", () => { - // A `toComplete` containing `=` still identifies its OWN flag name - // (checkIfFlagCompletion's flagWithEqual branch) — that never rescues - // an earlier, different dangling flag out of finalArgs, so - // ParseFlags() still fails on it: `sso add --type saml --metadata-file - // --attribute-mapping-file=` returns zero candidates with the Default - // directive — the error is "flag needs an argument: --metadata-file" - // — even though `--attribute-mapping-file` is itself a real flag with - // a registered file-extension completion. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -560,12 +427,6 @@ describe("respondToComplete", () => { describe("changed-flag tracking honors a long flag's real value consumption (CLI-1965 review)", () => { it("does not mark a value token as its own changed flag, so a still-required flag stays offered", () => { - // pflag's parseLongArg consumes the immediately following token as a - // non-boolean flag's value regardless of its shape — `--domains` (a - // string flag) consumes `--type` here, so `--type` itself was never - // parsed as a flag and must still be offered as required: `sso add - // --domains --type foo --typ` still offers `--type` with the - // NoFileComp directive. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -584,11 +445,6 @@ describe("respondToComplete", () => { describe("a boolean flag with an explicit `=` is still flag-VALUE completion (CLI-1965 review)", () => { it("does not fall through to noun completion for `--boolFlag=value`", () => { - // Cobra's checkIfFlagCompletion only resets a boolean flag back to noun - // completion in the no-`=` two-token case (`!flagWithEqual` guard); - // `--debug=maybe` keeps `flag` set and goes to flag-VALUE completion, - // which resolves to zero candidates: the Default directive, not the - // root command list. const result = respondToComplete(rootCommand, ["__complete", "--debug=maybe"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -598,10 +454,6 @@ describe("respondToComplete", () => { it.each(["bash", "zsh", "fish", "powershell"])( "returns zero candidates with the NoFileComp directive for `completion %s`", (shell) => { - // Cobra's InitDefaultCompletionCmd registers ValidArgsFunction: - // NoFileCompletions on the completion group and each of its shell - // leaves; getCompletions always calls a resolved command's own - // ValidArgsFunction, overwriting the directive outright. const result = respondToComplete(rootCommand, ["__complete", "completion", shell, ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }, @@ -610,10 +462,6 @@ describe("respondToComplete", () => { describe("help's own ValidArgsFunction resolves a second command path from root (CLI-1965 review)", () => { it("completes root subcommand names after `help`", () => { - // Cobra's auto-registered help command has its own ValidArgsFunction - // (command.go:1274-1290) that re-resolves everything after `help` from - // root — `help d` completes as if `d` were being completed at the - // root itself, not as an argument to `help`: `db`, `domains`. const result = respondToComplete(rootCommand, ["__complete", "help", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -622,8 +470,6 @@ describe("respondToComplete", () => { }); it("completes a resolved subcommand's own children after `help `", () => { - // `help db d` completes as `db d` would — db's own subcommands, not - // help's: `diff`, `dump`. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -632,25 +478,16 @@ describe("respondToComplete", () => { }); it("returns no candidates for a leaf command with no subcommands of its own", () => { - // `db dump` is a leaf; cobra's ValidArgsFunction loops over its empty - // Commands() and finds nothing, but still sets NoFileComp. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "dump", "s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("returns no candidates for an unresolved token directly under root", () => { - // Cobra's legacyArgs validator (args.go:28-37) only errors the - // "unknown command" case when the resolved command IS root and a - // token is left over: `help bogus d` -> zero candidates. const result = respondToComplete(rootCommand, ["__complete", "help", "bogus", "d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("still lists a resolved non-root command's subcommands past an unresolved token", () => { - // The same legacyArgs validator never errors for a non-root resolved - // command, even with leftover args — subcommands "will always accept - // arbitrary arguments": `help db bogus d` still offers db's own - // subcommands. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "bogus", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -659,8 +496,6 @@ describe("respondToComplete", () => { }); it("includes the synthetic `help` candidate itself when resolved back to root", () => { - // Cobra's help command is one of root's own Commands(), so completing - // help's arguments back at root re-lists help too: `help h` -> `help`. const result = respondToComplete(rootCommand, ["__complete", "help", "h"]); expect(result).toEqual({ candidates: [{ name: "help", description: "Help about any command" }], @@ -675,18 +510,13 @@ describe("respondToComplete", () => { { path: ["migration", "down"], flag: "last" }, { path: ["db", "reset"], flag: "last" }, ])("rejects a negative value for $path --$flag", ({ path, flag }) => { - // These flags are validated as strconv.ParseUint(s, 0, 64) values, - // which reject any sign prefix outright — unlike this tree's plain - // signed Flag.integer, whose generic regex accepts one: zero - // candidates with the Default directive for all three. + // These flags are validated as unsigned integers, which reject a leading sign, + // unlike this tree's plain signed Flag.integer regex. const result = respondToComplete(rootCommand, ["__complete", ...path, `--${flag}`, "-1", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still accepts a valid uint value, including the zero boundary", () => { - // Regression guard: the stricter check must not reject what real pflag - // accepts: `db reset --last 0 --d` still offers - // --debug/--dns-resolver/--db-url. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -704,20 +534,11 @@ describe("respondToComplete", () => { describe("--output's choice values are validated per-command, not the widened global union (CLI-1965 review)", () => { it("rejects db query's own local values (table/csv) everywhere else", () => { - // The global OutputFlag's choiceKeys is the UNION of root's - // 5-value enum and db query's own 3-value enum (go-output- - // flag.ts), but the root persistent --output only accepts - // env|pretty|json|toml|yaml: `--output table ""` -> zero candidates - // with the Default directive. const result = respondToComplete(rootCommand, ["__complete", "--output", "table", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("rejects the resource-command values (env/pretty/toml/yaml) under db query", () => { - // The reverse direction of the same defect: db query's own enum is - // json|table|csv only: `db query --output env ""` -> zero candidates - // with the Default directive, even though `env` is valid everywhere - // else. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -756,10 +577,6 @@ describe("respondToComplete", () => { describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { - // Plain int64 flags are validated as strconv.ParseInt(s, 0, 64) — base - // 0, so a `0x`-prefixed value is valid, unlike a decimal-only regex: - // `backups restore --timestamp 0x10 --p` still offers - // --profile/--project-ref. const result = respondToComplete(rootCommand, [ "__complete", "backups", @@ -775,13 +592,8 @@ describe("respondToComplete", () => { }); it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { - // Plain int64 flags are validated as strconv.ParseInt(s, 0, 64), a - // NARROWER signed range than the uint64 bound `Flag.integer` alone - // would suggest — 9223372036854775808 is a syntactically valid uint64 - // but exceeds int64 max by one: `backups restore --timestamp - // 9223372036854775808 --p` returns zero candidates with the Default - // directive, while int64's real bounds, 9223372036854775807 and - // -9223372036854775808, both still offer --profile/--project-ref. + // Validated against the signed int64 range, which is narrower than the uint64 + // bound `Flag.integer` alone would suggest. const overflow = respondToComplete(rootCommand, [ "__complete", "backups", @@ -814,11 +626,6 @@ describe("respondToComplete", () => { }); it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { - // Both are declared Flag.string in TS but validated as DurationVar - // values (cmd/gen.go:161,179), parsed via time.ParseDuration before - // Cobra generates completions: both `bogus` values return zero - // candidates with the Default directive, while `5s`/`1h` still - // complete normally. const queryTimeout = respondToComplete(rootCommand, [ "__complete", "gen", @@ -868,13 +675,6 @@ describe("respondToComplete", () => { }); it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { - // DurationVar values parse via time.ParseDuration, which accumulates - // into an int64 nanosecond count — a syntactically well-formed - // duration can still overflow that range: `gen types --query-timeout - // 2562048h --l` — one hour past the real max — returns zero - // candidates with the Default directive, matching `bogus`, while the - // exact int64 max, `2562047h47m16.854775807s`, still completes - // normally. const overflow = respondToComplete(rootCommand, [ "__complete", "gen", @@ -897,10 +697,6 @@ describe("respondToComplete", () => { }); it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { - // Declared Flag.string in TS but validated as a TimeVar constrained - // to time.RFC3339 (cmd/gen.go:178): `bogus` returns zero candidates - // with the Default directive, while a real RFC3339 timestamp still - // completes normally. const invalid = respondToComplete(rootCommand, [ "__complete", "gen", @@ -927,10 +723,8 @@ describe("respondToComplete", () => { }); it("rejects an out-of-range RFC3339 zone offset for gen bearer-jwt --exp", () => { - // time.Parse(time.RFC3339, s) independently caps the offset hour - // at 24 (not 23) and the offset minute at 60 (not 59): "+24:00" and - // "+00:60" parse successfully, while "+25:00" and "+00:61" both fail - // with "time zone offset hour/minute out of range". + // RFC3339 parsing caps the offset hour at 24 (not 23) and the minute at 60 (not + // 59), so "+24:00" and "+00:60" are valid while "+25:00" and "+00:61" are not. const outOfRangeHour = respondToComplete(rootCommand, [ "__complete", "gen", @@ -975,10 +769,7 @@ describe("respondToComplete", () => { }); it("accepts a comma-separated fractional second for gen bearer-jwt --exp", () => { - // time.Parse(time.RFC3339, s) accepts either `.` or `,` before the - // fractional-seconds digits: "2024-01-02T15:04:05,5Z" parses - // identically to "...05.5Z"; a bare "," with no following digit, or - // mixing both separators in one timestamp, both still fail. + // RFC3339 parsing accepts either `.` or `,` before the fractional-seconds digits. const commaFraction = respondToComplete(rootCommand, [ "__complete", "gen", @@ -1023,12 +814,6 @@ describe("respondToComplete", () => { }); it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { - // --jobs is validated as a UintVarP value (cmd/storage.go:107), the - // same as functions deploy/migration down/db reset above — but - // storage cp models it as Flag.string in TS, so the uint override - // must be consulted regardless of primitiveTag: `storage cp --jobs - // -1 --r` returns zero candidates with the Default directive, not - // --recursive. const result = respondToComplete(rootCommand, [ "__complete", "storage", @@ -1041,10 +826,6 @@ describe("respondToComplete", () => { }); it("rejects malformed CSV for a StringSliceVar-backed flag", () => { - // --domains is validated as a StringSliceVarP value (cmd/sso.go:158), - // CSV-split via encoding/csv at parse time; an unterminated quote - // fails that parse: `sso add --domains 'a,"b' --type` returns zero - // candidates with the Default directive, not --type. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -1069,10 +850,6 @@ describe("respondToComplete", () => { }); it("does not apply CSV validation to db reset --sql-paths (a plain StringArrayVar, not StringSliceVar)", () => { - // Go registers --sql-paths as a plain StringArrayVar (cmd/db.go:714) — - // no CSV parsing — unlike every other variadic string flag in this - // tree, so a value containing an unbalanced quote must still be - // accepted. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -1089,11 +866,6 @@ describe("respondToComplete", () => { describe("an attached-value shorthand cluster is validated even when the owning flag is boolean (CLI-1965 review)", () => { it("rejects an invalid boolean value attached via `=` to a boolean shorthand", () => { - // pflag treats `-f=value` as an explicit value for a boolean shorthand - // too (pflag@v1.0.10/flag.go:1005-1033) — an owning flag being boolean - // does not, on its own, mean there is nothing to validate: `storage - // cp -r=maybe --j` returns zero candidates with the Default - // directive, not --jobs. const result = respondToComplete(rootCommand, [ "__complete", "storage", @@ -1121,27 +893,17 @@ describe("respondToComplete", () => { it.each(["--help=false", "--help=true", "-h=false"])( "treats %s the same as a bare --help", (token) => { - // pflag's boolValue.Set marks the flag Changed on either spelling, - // and cobra's helpOrVersionFlagPresent checks .Changed, not the - // parsed value (completions.go:530-537): `--help=false --d` and - // `--help=true --d` both return zero candidates with the - // NoFileComp directive. const result = respondToComplete(rootCommand, ["__complete", token, "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }, ); it("treats --version=false the same as a bare --version, at the root", () => { - // `--version=false br` returns zero candidates with the NoFileComp - // directive, not `branches`. const result = respondToComplete(rootCommand, ["__complete", "--version=false", "br"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("does not treat --help=maybe as Changed — an invalid boolean value is an unresolved-flag parse error instead", () => { - // An invalid value fails pflag's own Set() before Changed is ever - // examined, so this is Case 0 (Default), not the help short-circuit - // (NoFileComp). const result = respondToComplete(rootCommand, ["__complete", "--help=maybe", "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -1149,11 +911,6 @@ describe("respondToComplete", () => { describe("a `--` consumed as a preceding flag's value is not a genuine terminator (CLI-1965 review)", () => { it("still offers a flag name after `--` was consumed as a value-taking flag's own value", () => { - // pflag's parseLongArg consumes the very next token unconditionally as - // a non-boolean flag's value — including a literal `--` — so it never - // reaches pflag's own end-of-flags sentinel check (pflag@v1.0.10/ - // flag.go:949-952): `db dump --file -- --s` still offers --schema, - // not zero candidates. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -1167,9 +924,6 @@ describe("respondToComplete", () => { }); it("still disables flag completion for a genuine, unconsumed `--` sentinel", () => { - // Regression guard: only a `--` that isn't claimed as a preceding - // flag's value is a real terminator: `db dump -- --s` returns zero - // candidates with the Default directive. const result = respondToComplete(rootCommand, ["__complete", "db", "dump", "--", "--s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -1177,29 +931,17 @@ describe("respondToComplete", () => { describe("a bare `-` is a positional argument, not a flag (CLI-1965 review)", () => { it("keeps the subcommand-listing gate closed when a bare `-` survives as leftover", () => { - // pflag's isFlagArg requires at least 2 characters, so a bare `-` is - // never flag-shaped — but it also never removes itself from cobra's - // leftover finalArgs the way a matched command name does, so it keeps - // the `len(finalArgs) == 0` subcommand-listing gate closed: `sso - - // --debug a` returns zero candidates with the Default directive, not - // `add`. const result = respondToComplete(rootCommand, ["__complete", "sso", "-", "--debug", "a"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still lets the descent continue past a bare `-` to a real subcommand match", () => { - // Regression guard: a bare `-` must not stop the descent the way an - // unmatched real token does: `db - dump --da` still offers - // --data-only. const result = respondToComplete(rootCommand, ["__complete", "db", "-", "dump", "--da"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("--data-only"); }); it("still resolves help's own second command-path lookup past a bare `-`", () => { - // The Case-2 "preceding token is flag-shaped" check must also exclude - // a bare `-`, or it hard-stops before ever reaching help's dispatch: - // `help db - d` still lists db's own subcommands diff/dump. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "-", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -1236,19 +978,12 @@ describe("resolveCommandPath", () => { }); it("skips flag-shaped tokens without stopping descent, and excludes them from leftoverArgs", () => { - // `--debug` is a boolean global flag — it and every genuinely-consumed - // flag token are excluded from `leftoverArgs` entirely (not just skipped - // during subcommand matching), since `leftoverArgs` represents cobra's - // *positional* `finalArgs`, used to gate subcommand-name completion. const result = resolveCommandPath(rootCommand, ["--debug", "migration", "list"]); expect(result.matchedPath).toEqual(["migration", "list"]); expect(result.leftoverArgs).toEqual([]); }); it("also excludes a value-taking flag's consumed value token from leftoverArgs", () => { - // `-o` (the global --output choice flag) is non-boolean, so it consumes - // "json" as its value — "json" must not be treated as an extra - // positional token even though it isn't itself flag-shaped. const result = resolveCommandPath(rootCommand, ["-o", "json", "migration", "list"]); expect(result.matchedPath).toEqual(["migration", "list"]); expect(result.leftoverArgs).toEqual([]); @@ -1267,8 +1002,8 @@ describe("collectInScopeFlags", () => { const { commandChain } = resolveCommandPath(rootCommand, ["branches", "list"]); const flags: ReadonlyArray = collectInScopeFlags(rootCommand, commandChain); const names = flags.map((flag) => flag.name); - expect(names).toContain("debug"); // root global flag - expect(names).toContain("project-ref"); // branches list's own local flag + expect(names).toContain("debug"); + expect(names).toContain("project-ref"); const debugFlag = flags.find((flag) => flag.name === "debug"); expect(debugFlag).toEqual({ @@ -1325,18 +1060,11 @@ describe("collectInScopeFlags", () => { }); it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { - // pflag's VisitAll sorts by canonical (long) flag name; cobra's - // completion path walks the inherited (ancestor) set first, then the - // resolved command's own set — TWO separately-sorted runs, not one - // merged alphabetical list: `db dump -` lists --agent, --create-ticket, - // --debug, ... alphabetically, THEN a second alphabetical run starting - // --completions (the built-ins join the own block), --data-only, - // --db-url, --dry-run, .... const { commandChain } = resolveCommandPath(rootCommand, ["db", "dump"]); const names = collectInScopeFlags(rootCommand, commandChain).map((flag) => flag.name); - const inheritedEnd = names.indexOf("yes"); // last inherited flag, alphabetically - const ownStart = names.indexOf("completions"); // first own flag, alphabetically (a built-in) + const inheritedEnd = names.indexOf("yes"); + const ownStart = names.indexOf("completions"); expect(inheritedEnd).toBeGreaterThanOrEqual(0); expect(ownStart).toBeGreaterThan(inheritedEnd); @@ -1344,8 +1072,6 @@ describe("collectInScopeFlags", () => { const ownBlock = names.slice(ownStart); expect(inheritedBlock).toEqual([...inheritedBlock].sort((a, b) => a.localeCompare(b))); expect(ownBlock).toEqual([...ownBlock].sort((a, b) => a.localeCompare(b))); - // --help is db dump's own NonInherited flag (every command registers its - // own), not part of the shared inherited block. expect(inheritedBlock).not.toContain("help"); expect(ownBlock).toContain("help"); }); @@ -1355,8 +1081,7 @@ describe("collectInScopeFlags", () => { rootCommand, resolveCommandPath(rootCommand, []).commandChain, ); - // `output-format` is TS-only surface with no Go equivalent, so it isn't - // asserted here — every OTHER root flag name must still come out sorted. + // `output-format` is TS-only, so it's excluded from the ordering check. const names = atRoot.map((flag) => flag.name).filter((name) => name !== "output-format"); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); }); @@ -1491,19 +1216,14 @@ describe("tryComplete", () => { exit: (code) => { exits.push(code); }, - // A no-op here keeps this suite's own focus (candidate-computation - // wiring, not telemetry) pure and synchronous-fast — the real - // production capture (`captureCompleteTelemetryEffect`, - // `defaultCompleteDeps`'s own default) is covered separately in - // `complete.integration.test.ts`. + // No-op: telemetry capture is covered separately in complete.integration.test.ts. captureTelemetry: async () => {}, ...overrides, }; return { deps, stdoutWrites, stderrWrites, exits }; } - // `tryComplete` returns `Promise` — it awaits - // `deps.captureTelemetry` before calling `deps.exit`. + // `tryComplete` awaits `deps.captureTelemetry` before calling `deps.exit`. it("returns false and does nothing for non-__complete argv", async () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); expect(await tryComplete(deps)).toBe(false); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index e9db64417e..a522854376 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -141,9 +141,8 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot const cliArgs = yield* CliArgs; const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer)); - // An explicit Go --output is a complete format choice (even `-o pretty` - // must keep its human table), so the agent JSON default only applies - // when that flag is absent. + // An explicit --output is a complete format choice (even `-o pretty` keeps its + // human table), so the agent JSON default only applies when it's absent. const outputFormat = resolveAgentOutputFormat({ explicitOutputFormat, goOutputFormat: goOutput, @@ -152,8 +151,6 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), }); - // Build args to prepend to every proxy exec call. - // --output: use explicit --output if set, otherwise map from --output-format. const globalArgs: string[] = []; if (Option.isSome(goOutput)) { globalArgs.push("--output", goOutput.value); @@ -170,12 +167,9 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot if (createTicket) globalArgs.push("--create-ticket"); if (agent !== "auto") globalArgs.push("--agent", agent); - // Go's `-o {json,yaml,toml,env,csv}` selects a machine encoder the - // handler writes via `output.raw`. Keep the text layer (so errors still - // render as red text on stderr, matching Go), but suppress its progress - // spinner — otherwise clack writes ANSI to stdout and corrupts the - // payload (CLI-1546). `-o pretty` / `-o table` (`db query`'s human - // default) / no `-o` keep the normal text/json layers. + // Machine formats keep the text layer's error rendering but suppress the progress + // spinner, which would otherwise corrupt the stdout payload. `-o pretty`/`-o table` + // (db query's human default) and no `-o` keep the normal text/json layers. const goFmt = Option.getOrUndefined(goOutput); const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty" && goFmt !== "table"; const outputLayer = isGoMachineFormat diff --git a/apps/cli/src/command-internal/api-keys.format.ts b/apps/cli/src/command-internal/api-keys.format.ts index 5ade6c3483..903fc3d192 100644 --- a/apps/cli/src/command-internal/api-keys.format.ts +++ b/apps/cli/src/command-internal/api-keys.format.ts @@ -2,17 +2,10 @@ import type { ApiKeyResponse } from "@supabase/api/effect"; type ApiKey = typeof ApiKeyResponse.Type; -/** - * Masking placeholder Go substitutes for a nullable-null api key value - * (`apps/cli-go/internal/projects/apiKeys/api_keys.go:61-66`, deleted in - * CLI-1970; last present at commit 7b469f5b3). - */ +/** Placeholder returned for a null or missing api key value. */ const API_KEY_MASK = "******"; -/** - * Reproduces Go's `apiKeys.toValue` (`api_keys.go:61-66`): return the api key - * value, or the `******` mask when the value is nullable-null / absent. - */ +/** Returns the api key value, or the mask when it's null or absent. */ export function apiKeyValue(value: string | null | undefined): string { return value === undefined || value === null ? API_KEY_MASK : value; } @@ -25,10 +18,9 @@ function envSuffix(entry: ApiKey): string { } /** - * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-68`): - * uppercase the name (with `default` publishable → `PUBLISHABLE`), wrap as - * `SUPABASE__KEY`, fall back to `"******"` when the api_key value is - * nullable-null. Shared by `branches get` and `projects api-keys`. + * Builds `SUPABASE__KEY` env entries from api keys, uppercasing the name (the + * default publishable key maps to `PUBLISHABLE`). Shared by `branches get` and + * `projects api-keys`. */ export function apiKeysToEnv(keys: ReadonlyArray): Record { const envs: Record = {}; diff --git a/apps/cli/src/command-internal/api-url.ts b/apps/cli/src/command-internal/api-url.ts index ff22b6a9e3..8a91bbf436 100644 --- a/apps/cli/src/command-internal/api-url.ts +++ b/apps/cli/src/command-internal/api-url.ts @@ -1,11 +1,9 @@ import { goUrlParse } from "./storage-url.ts"; /** - * 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 `storage-credentials.ts` and - * `local-config-values.ts` both need this exact computation. + * Derives the local API URL: an explicit `api.external_url` wins, otherwise + * `://:` from `api.tls.enabled` and `api.port`. Hoisted here + * because `storage-credentials.ts` and `local-config-values.ts` both need it. */ export function resolveApiExternalUrl( config: { @@ -19,8 +17,7 @@ export function resolveApiExternalUrl( 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. + // Brackets an IPv6 host, e.g. `[::1]:5432`. const hostPort = hostname.includes(":") ? `[${hostname}]:${config.port}` : `${hostname}:${config.port}`; @@ -28,20 +25,9 @@ export function resolveApiExternalUrl( } /** - * Go's `Config.Validate` rewrite of `Studio.ApiUrl` (`pkg/config/config.go:1074-1078`): - * ```go - * } else if parsed.Host == "" || parsed.Host == c.Hostname { - * c.Studio.ApiUrl = c.Api.ExternalUrl - * } - * ``` - * Runs as the last step of `Config.Load` (`config.go:882`), so by the time - * `start` builds Studio's env, `studio.api_url` has already been rewritten to - * the resolved API external URL (the Kong URL) whenever its host is empty - * (a relative/schemeless value) or matches the bare local hostname exactly - * (no port) — which is the default-config case, since `studio.api_url` - * defaults to `http://127.0.0.1` and `Hostname` defaults to the same - * `"127.0.0.1"`. An explicit, non-matching host (e.g. a custom domain, or a - * host:port pair) is left untouched. + * Rewrites `studio.api_url` to the resolved API external URL when its host is empty or + * matches the bare local hostname with no port (the default-config case). An explicit + * non-matching host, or a host:port pair, is left untouched. */ export function resolveStudioApiUrl( rawApiUrl: string, diff --git a/apps/cli/src/command-internal/api-url.unit.test.ts b/apps/cli/src/command-internal/api-url.unit.test.ts index c569b4e3ce..643b0579cd 100644 --- a/apps/cli/src/command-internal/api-url.unit.test.ts +++ b/apps/cli/src/command-internal/api-url.unit.test.ts @@ -3,9 +3,6 @@ import { describe, expect, it } from "vitest"; import { resolveStudioApiUrl } from "./api-url.ts"; describe("resolveStudioApiUrl", () => { - // Go's `Config.Validate` (`pkg/config/config.go:1074-1078`): a default - // `studio.api_url` (`http://127.0.0.1`) has host `127.0.0.1`, matching the - // default hostname, so it's rewritten to the resolved API external URL. it("rewrites the default studio.api_url to the API external URL", () => { expect(resolveStudioApiUrl("http://127.0.0.1", "127.0.0.1", "http://127.0.0.1:54321")).toBe( "http://127.0.0.1:54321", @@ -25,8 +22,6 @@ describe("resolveStudioApiUrl", () => { }); it("leaves a value with the matching host but an explicit port untouched", () => { - // Go's `parsed.Host` includes the port when one is present, so `127.0.0.1:3000` - // does not equal the bare `Hostname` `127.0.0.1` and is not rewritten. expect( resolveStudioApiUrl("http://127.0.0.1:3000", "127.0.0.1", "http://127.0.0.1:54321"), ).toBe("http://127.0.0.1:3000"); diff --git a/apps/cli/src/command-internal/bitbucket-pipeline.ts b/apps/cli/src/command-internal/bitbucket-pipeline.ts index af16c2ab42..7400b3bf31 100644 --- a/apps/cli/src/command-internal/bitbucket-pipeline.ts +++ b/apps/cli/src/command-internal/bitbucket-pipeline.ts @@ -1,27 +1,15 @@ /** - * `BITBUCKET_CLONE_DIR` — exported so `local-project-context.ts` can install it from a - * project dotenv file into `process.env` before this check ever runs. Unlike - * `SUPABASE_SERVICES_HOSTNAME` (deliberately NOT installed, see that file's own doc comment), - * Go's `os.Getenv("BITBUCKET_CLONE_DIR")` read (`apps/cli-go/internal/utils/docker.go:401`) lives - * inside `DockerStart`, a regular function invoked during the command's own `Run()`, well after - * `flags.LoadConfig` -> `godotenv.Load` has already installed dotenv keys into the process env - * (`pkg/config/config.go:786-791,1261`) — not in a package-level `var` initializer evaluated - * before `godotenv.Load` ever runs (review: PRRT_kwDOErm0O86VmHkm). + * Exported so `local-project-context.ts` can install this key from a project dotenv file + * into `process.env` before {@link isBitbucketPipeline} ever runs. */ export const BITBUCKET_CLONE_DIR_ENV_KEY = "BITBUCKET_CLONE_DIR"; /** - * Whether the current process is running inside a Bitbucket Pipelines runner, - * mirroring Go's `os.Getenv("BITBUCKET_CLONE_DIR") != ""` check - * (`apps/cli-go/internal/utils/docker.go:401`). Bitbucket's Docker-in-Docker - * runner disallows named volumes and `--security-opt` - * (https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands), - * so every container-runtime call site that creates volumes or sets security - * options needs this same check. - * - * Hoisted here because it is needed by ≥2 call sites: `docker-run.layer.ts` - * (`docker run`, e.g. `db dump`/`db test`) and `start`'s per-service container - * creation (`command-internal/db-bootstrap/container-lifecycle.ts`). + * Whether the process is running inside a Bitbucket Pipelines runner, whose + * Docker-in-Docker environment disallows named volumes and `--security-opt`. Hoisted here + * for reuse across every container-runtime call site that creates volumes or sets security + * options. + * @see https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands */ export function isBitbucketPipeline(): boolean { const value = globalThis.process.env[BITBUCKET_CLONE_DIR_ENV_KEY]; diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index 224eab280d..0a19e5a22f 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -22,7 +22,7 @@ type BranchLookupBranches = typeof V1ListAllBranchesOutput.Type; * `SchemaError` rejecting the response body), surfaces through the * caller-supplied network-error factory/message. * - * Each caller supplies its OWN error classes and message templates, so the + * Each caller supplies its own error classes and message templates, so the * exact wording stays theirs — this helper only owns the status/transport * dispatch, not the message text. Pair with `Effect.asSome` so the 404 case * collapses cleanly into `Option.none()`: @@ -45,8 +45,8 @@ export function classifyProjectLookupError(opts: { } return cause.response.text.pipe( Effect.orElseSucceed(() => ""), - // Cap + strip control chars, matching `mapHttpError`'s defence-in-depth - // so an oversized / control-char body can't bloat JSON output or inject ANSI. + // Caps and strips control chars so an oversized or control-char body can't + // bloat JSON output or inject ANSI. Effect.map(sanitizeErrorBody), Effect.flatMap((body) => Effect.fail( @@ -72,23 +72,11 @@ export function classifyProjectLookupError(opts: { } /** - * Acquires a Management API client for a best-effort branch-name lookup, - * total (never fails, resolves `None` on any acquisition failure): - * - * 1. `Effect.serviceOption(CommandPlatformApi)` — the cheapest path; existing - * tests provide this directly, and any runtime that eagerly built the - * typed client (e.g. `link`/`branches`) already has it in scope. - * 2. Otherwise `Effect.serviceOption(CommandPlatformApiFactory)` → `factory.make`, - * with every failure (no token, invalid token, network, decode) caught. - * This is the path a token-optional runtime like `status`'s needs: the - * factory's own layer build never resolves a token or touches the - * network — that only happens here, lazily, exactly when a branch lookup - * is actually attempted. - * - * Neither service being in scope (a runtime that wires up neither) also - * degrades to `None` rather than a compile-time requirement — this effect's - * own type carries no `CommandPlatformApi`/`CommandPlatformApiFactory` - * requirement at all, thanks to `Effect.serviceOption`. + * Acquires a Management API client for a best-effort branch lookup; never fails, resolving + * `None` on any acquisition failure. Prefers an already-built `CommandPlatformApi`; falls + * back to `CommandPlatformApiFactory` for a token-optional runtime like `status`'s, since the + * factory only resolves a token and touches the network here, lazily, when a lookup is + * actually attempted. */ const acquireBranchLookupApi = Effect.fnUntraced(function* () { const direct = yield* Effect.serviceOption(CommandPlatformApi); @@ -103,29 +91,23 @@ const acquireBranchLookupApi = Effect.fnUntraced(function* () { ); }); -// A branch-name lookup is pure decoration for any caller and must never -// dominate its latency. The generated client's own retry policy (60s -// attempts × 5 transport retries — `packages/api/src/internal/client.ts:208-229`) -// would otherwise let a single blackholed API stall a caller for ~6 minutes. -// Exported so other bounded-but-best-effort probes in this codebase (e.g. -// `push.branch-target.ts`'s live `getProject` target-detection probe) share -// the same duration + rationale instead of re-deriving it. +/** + * A branch-name lookup is pure decoration and must never dominate a caller's latency — + * without this bound, the generated client's own retry policy could stall a caller for + * several minutes against a blackholed API. Shared with other bounded, best-effort probes + * (e.g. `push.branch-target.ts`'s `getProject` probe) so they don't re-derive the duration. + */ export const BRANCH_LOOKUP_TIMEOUT = Duration.seconds(5); /** - * Best-effort branch-name lookup against `parentRef`'s branches, returning - * the matching branch's `name` (or `undefined` on no match/any failure — the - * degradation point every caller relies on). Shows `options.spinnerLabel` in - * text mode when supplied, but only once an API client is actually - * available — an acquisition failure degrades silently, before ever touching - * the spinner. + * Best-effort branch-name lookup against `parentRef`'s branches, returning the matching + * branch's `name`, or `undefined` on no match or any failure. Shows `options.spinnerLabel` + * in text mode once an API client is available; an acquisition failure degrades silently, + * before the spinner is shown. * - * The WHOLE acquisition-and-listing attempt is hard-bounded by - * {@link BRANCH_LOOKUP_TIMEOUT}; a timeout degrades exactly like any - * other failure. The spinner's cleanup runs via `Effect.ensuring` (not a - * plain sequential `yield*`) so it's guaranteed to fire even when the timeout - * interrupts the in-flight listing call, not just on its normal - * success/failure completion. + * The whole acquisition-and-listing attempt is bounded by {@link BRANCH_LOOKUP_TIMEOUT}; a + * timeout degrades like any other failure. Spinner cleanup runs via `Effect.ensuring` so it + * still fires when the timeout interrupts the in-flight listing call. */ export const findBranchName = Effect.fnUntraced(function* ( parentRef: string, @@ -148,8 +130,8 @@ export const findBranchName = Effect.fnUntraced(function* ( .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); }).pipe( Effect.timeout(BRANCH_LOOKUP_TIMEOUT), - // Best-effort: any transport/status/decode failure OR the timeout above - // degrades below — this helper must never fail on a flaky/slow lookup. + // Any failure or the timeout above degrades to `None`; this helper never fails on a + // flaky or slow lookup. Effect.catch(() => Effect.succeed(Option.none())), ); diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index 944e6ae811..fe31b8f853 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -11,20 +11,17 @@ export interface ColorStream { } /** - * Port of termenv's colour-profile gate, which is what the established lipgloss default - * renderer consults (`lipgloss/renderer.go` → `termenv.EnvColorProfile`): + * Colour-detection gate following the NO_COLOR/CLICOLOR convention the established CLI output + * uses: * - * 1. `NO_COLOR` non-empty → no colour, beats everything (`EnvNoColor`). - * 2. `CLICOLOR=0` → no colour, unless forced (`EnvNoColor`). - * 3. `CLICOLOR_FORCE` set and not `"0"` → colour even when piped (the - * Ascii→ANSI promotion). + * 1. `NO_COLOR` non-empty → no colour, beats everything. + * 2. `CLICOLOR=0` → no colour, unless forced. + * 3. `CLICOLOR_FORCE` set and not `"0"` → colour even when piped. * 4. `CI` non-empty → treated as non-TTY. - * 5. Otherwise: the stream must be a colour-capable TTY. `hasColors()` is - * faithful on Bun TTYs (it also covers `TERM=dumb`) and absent on piped - * streams. + * 5. Otherwise: the stream must be a colour-capable TTY. `hasColors()` is faithful on Bun + * TTYs (it also covers `TERM=dumb`) and absent on piped streams. * - * termenv does NOT honor Node's `FORCE_COLOR` — only the `CLICOLOR*` pair — - * so neither does this gate. + * Node's `FORCE_COLOR` is not honored — only the `CLICOLOR*` pair. */ function supportsColor(stream: ColorStream): boolean { const env = process.env; @@ -38,28 +35,20 @@ function supportsColor(stream: ColorStream): boolean { } /** - * Ports of `utils.Aqua` / `utils.Bold`. + * Renders plain text when the stream is not a TTY (piped output, CI, tests). Node's + * `styleText` would handle that via `validateStream`, but Bun (1.3.14, the only runtime the + * CLI ships on) does not implement it — it styles unconditionally, even when the stream is + * piped and even under `NO_COLOR=1`. The gate is implemented here instead — see + * {@link supportsColor} — and `validateStream: false` is passed explicitly so this gate stays + * authoritative even if a future Bun starts validating. * - * The established behavior uses lipgloss, which auto-detects the output profile and renders **plain** - * text when the stream is not a TTY (piped output, CI, tests). Node's - * `styleText` would mirror that via `validateStream`, but Bun (1.3.14, the - * only runtime the CLI ships on) does not implement `validateStream`: it - * styles unconditionally, even when the stream is piped and even under - * `NO_COLOR=1`. The gate is therefore implemented here — see - * {@link supportsColor} — and `validateStream: false` is passed - * explicitly so that our gate stays authoritative even if a future Bun starts - * validating. + * `stream` defaults to `process.stderr` because every call site styles progress/suggestion + * lines written to stderr. A caller styling content 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 would corrupt the piped output + * with ANSI escapes. * - * `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`. + * Uses `"cyan"`, the closest Node `styleText` colour to the established bright-cyan value. */ export function aqua(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("cyan", text, { validateStream: false }) : text; @@ -69,17 +58,17 @@ export function bold(text: string, stream: ColorStream = process.stderr): string return supportsColor(stream) ? styleText("bold", text, { validateStream: false }) : text; } -/** Port of `utils.Yellow` — lipgloss colour "11" (bright yellow). */ +/** Renders in bright yellow. */ export function yellow(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("yellow", text, { validateStream: false }) : text; } -/** Port of `utils.Red` — lipgloss colour "9" (bright red). */ +/** Renders in bright red. */ export function red(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("red", text, { validateStream: false }) : text; } -/** Port of `utils.Green` — lipgloss colour "10" (bright green). */ +/** Renders in bright green. */ export function green(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("green", text, { validateStream: false }) : text; } diff --git a/apps/cli/src/command-internal/colors.unit.test.ts b/apps/cli/src/command-internal/colors.unit.test.ts index 04bde6cb2f..44c7550096 100644 --- a/apps/cli/src/command-internal/colors.unit.test.ts +++ b/apps/cli/src/command-internal/colors.unit.test.ts @@ -3,21 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ColorStream } from "./colors.ts"; import { aqua, bold, green, red, yellow } from "./colors.ts"; -// Bun's `util.styleText` ignores `validateStream` (verified on Bun 1.3.14: a -// piped stdout still gets `\x1b[36m…\x1b[39m`, even under NO_COLOR=1), so -// `colors.ts` implements termenv's gate itself — the same decision -// order the established lipgloss-style default renderer uses. These tests pin -// that gate deterministically with fake -// streams and stubbed env vars; a piped stream (no `hasColors`) must yield -// PLAIN text, exactly like the established behavior under a piped stdout. +// Bun's `util.styleText` ignores `validateStream`, so `colors.ts` implements the colour gate +// itself. These tests pin that gate deterministically with fake streams and stubbed env vars. const colorTty: ColorStream = { hasColors: () => true }; const monoTty: ColorStream = { hasColors: () => false }; const piped: ColorStream = {}; beforeEach(() => { - // Neutralize the ambient environment (CI sets `CI`, developers may set - // NO_COLOR) so each case controls the gate's inputs exactly. Empty string - // reads as unset for every variable termenv consults. + // Neutralize the ambient environment (CI sets `CI`, developers may set NO_COLOR) so each + // case controls the gate's inputs exactly. Empty string reads as unset for every variable. vi.stubEnv("NO_COLOR", ""); vi.stubEnv("CLICOLOR", ""); vi.stubEnv("CLICOLOR_FORCE", ""); @@ -77,9 +71,8 @@ describe("colors TTY gating (termenv parity)", () => { }); it("defaults to gating on stderr when no stream is given", () => { - // The live TTY-ness of the test process's stderr is environment-dependent, - // so pin the gate closed via the CI branch: the default-stream form must - // still come back plain, proving the default threads through the gate. + // The test process's real stderr TTY-ness is environment-dependent, so pin the gate closed + // via CI to prove the default stream threads through the gate. vi.stubEnv("CI", "true"); expect(aqua("supabase")).toBe("supabase"); expect(bold("text")).toBe("text"); diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 591ef884cd..4ef17d7449 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -81,20 +81,10 @@ import { } from "../commands/config/pull/pull.errors.ts"; /** - * `config pull`'s plan/apply run-core (CLI-1272): the pieces hoisted out of - * `commands/config/pull/pull.handler.ts`/`pull.format.ts` so the `supabase - * pull` orchestrator (`commands/pull/`) can drive the SAME plan, apply, and - * emission-adjacent formatting logic as the standalone `config pull` command - * without one command family importing another's command-local files - * (`code-structure.unit.test.ts`'s "prevents commands from importing other - * command internals" rule). `commands/config/pull/pull.handler.ts` still owns - * the CLI-facing `configPull` entry point and the reusable `runConfigPull` - * body (steps 1-4 and 9-15); this module owns everything a second caller also - * needs verbatim: opening the base config source (steps 2-3), planning (steps - * 5-8), applying (steps 12-13), and the render/payload builders both callers' - * confirmation/summary/machine output share. `pull.format.ts` re-exports the - * payload/render pieces that moved here so its own (unmodified) test suite - * keeps resolving them from the same path. + * `config pull`'s plan/apply core, hoisted out of `commands/config/pull/` so + * `commands/pull/` can reuse the same plan/apply/formatting logic without importing + * another command's internals. `pull.handler.ts` still owns the CLI entry point; + * `pull.format.ts` re-exports the payload/render pieces that moved here. */ export interface ConfigPullPlanRequest { @@ -115,15 +105,15 @@ export interface ConfigPullRunPlan { } /** - * The run's actual outcome, known only after the confirmation prompt (or - * `--dry-run`) resolves — layered on top of {@link ConfigPullPlan}, - * which only knows what WOULD be written. `dryRun` and `declined` are - * mutually exclusive: a `--dry-run` run never reaches the prompt. + * The run's actual outcome, known only after the confirmation prompt (or `--dry-run`) + * resolves — layered on top of {@link ConfigPullPlan}, which only knows what would be + * written. `dryRun` and `declined` are mutually exclusive: a `--dry-run` run never reaches + * the prompt. */ export interface ConfigPullOutcome { readonly dryRun: boolean; - /** The confirmation prompt (§1.4) was declined — every planned write - * becomes `skipped_reason: "declined"` instead of being applied. */ + /** The confirmation prompt was declined — every planned write becomes + * `skipped_reason: "declined"` instead of being applied. */ readonly declined: boolean; } @@ -138,38 +128,33 @@ export interface ConfigPullContext { * (`supabase/config.toml`). */ readonly configPath: string; readonly format: ConfigFormat; - /** Matched `[remotes.]` block the DIFF operand was merged from — - * independent of `destination` (a brand-new `[remotes.*]` block being - * CREATED has no applied overlay to diff against yet). Mirrors `config - * diff`'s own `target.local_scope`. */ + /** Matched `[remotes.]` block the diff operand was merged from — independent of + * `destination` (a brand-new `[remotes.*]` block being created has no applied overlay to + * diff against yet). Mirrors `config diff`'s own `target.local_scope`. */ readonly appliedRemote: string | undefined; readonly destination: ConfigPullDestination; } /** - * Version of the machine payload's own shape — bump when the payload - * contract changes incompatibly. A NEW payload, independent of `config - * diff`'s `CONFIG_DIFF_PAYLOAD_VERSION` (never bumped by this file). + * Version of the payload shape below; bump when the contract changes incompatibly. + * Independent of `config diff`'s `CONFIG_DIFF_PAYLOAD_VERSION`. */ export const CONFIG_PULL_PAYLOAD_VERSION = 1; /** - * A change's actual disposition once the confirmation prompt (and - * `--dry-run`) are known — broader than {@link ConfigPullSkipReason} - * (the pure PLANNING-time reason a change was never even attempted): a - * change `planConfigPull` planned to write still ends up unwritten when + * A change's actual disposition once the confirmation prompt (and `--dry-run`) are known — + * broader than {@link ConfigPullSkipReason} (the pure planning-time reason a change was never + * even attempted): a change `planConfigPull` planned to write still ends up unwritten when * the run is a dry run or the user declined. */ type ConfigPullChangeSkipReason = ConfigPullSkipReason | "declined" | "dry_run"; /** - * The collision message (`ConfigPullRemoteLabelCollisionError`) — - * worded differently depending on WHICH of `pull.scope.ts`'s two - * `label_collision` situations applies, and whether the label came from an - * explicit `--remote-label` or was derived from a branch name (only - * `--remote-label` can ever reach the "a DIFFERENT block already tracks this - * ref" situation — see `resolveConfigPullDestination`'s own doc - * comment for why a branch-derived label never does). + * The collision message (`ConfigPullRemoteLabelCollisionError`), worded differently + * depending on which of `pull.scope.ts`'s two `label_collision` situations applies and + * whether the label came from an explicit `--remote-label` or a branch name (only + * `--remote-label` can reach the "a different block already tracks this ref" situation — see + * `resolveConfigPullDestination`'s own doc comment for why a branch-derived label never does). */ function configPullLabelCollisionMessage( scopeResult: ConfigPullScopeLabelCollision, @@ -233,50 +218,15 @@ function configPullRefusalRemediation(reason: ConfigEditRefusalReason): string { } /** - * Plan §1.9's convergence check — run once the fixpoint expansion - * (`expandConfigPullChangeSet`, `pull.plan.ts`) has settled, and BEFORE - * `--dry-run` returns (a planner defect must be caught even in a preview - * run) — against the fixpoint's OWN residual (the last round's re-diff, i.e. - * the state once every currently-planned write has been applied). - * - * A residual change at a path this run just planned to write means the write - * didn't actually converge — a defect in THIS command's own planner, never a - * user-facing condition, surfaced as a typed `ConfigPullPlanDefectError` - * (`impossibleState`) rather than a crash: nothing has been written yet at - * this point (this check runs BEFORE the dry-run/prompt/write/validation - * steps), so the error can truthfully say so. A residual `unmanaged` path - * would mean the very value this run just wrote made itself invisible to the - * projection again. Most surviving `@supabase/config` prunes - * (`DISABLED_SENTINEL_PRUNES`, most of `applyRawPresenceMask`) are - * conditional on exactly the state a write establishes — a container's OWN - * decoded `enabled`, or the raw file's OWN declared-ness — so writing a - * value usually satisfies the very condition that would otherwise hide it - * again. CLI-2314 retired the one `DISABLED_SENTINEL_PRUNES`-family prune - * that didn't have this property (`auth.oauth_server`'s old unconditional - * removal, ignoring what had just been written) — see ADR 0021's CLI-2314 - * addendum; `pull.integration.test.ts`'s "no longer trips the ADR 0021 - * unpushable warning" case pins the resulting behavior for that family. This - * branch DOES still have a known live trigger, though, via a DIFFERENT - * cross-path prune: `applyDisabledSentinels`'s own "cross-section rule" - * (`project-config.ts`, search "Cross-section rule: the email rate limit") - * deletes `auth.rate_limit.email_sent` whenever `auth.email.smtp.enabled` is - * explicitly `false` — checked on BOTH arms, not gated by raw presence. On - * the API arm this only spares `email_sent` when the response is genuinely - * SPARSE (never reports `smtp_host` at all, so `enabled` decodes as absent - * rather than an explicit `false`) — an ordinary response with - * `smtp_host: ""` (the common "SMTP not configured" shape) still prunes it - * there too, leaving nothing to diff. So the live trigger is narrow: a - * sparse remote response reporting a real `rate_limit_email_sent` while - * omitting `smtp_host` entirely, pulled into a LOCAL document that also - * never declares `[auth.email.smtp]` (so `applyRawPresenceMask` masks the - * just-written value again on the residual check) — see - * `pull.integration.test.ts`'s matching case for the exact construction. - * Retained as a structural safety net for any OTHER asymmetric/cross-path - * prune too, not dead code: surfaced as a `"unpushable"` warning, reusing - * the SAME - * `plan.warnings` / `renderConfigPullText` "Warnings:" hook the - * planner's own `dual_scope`/`duplicates_root`/`array_drift` warnings - * already render through, rather than adding a new payload field. + * Convergence check run after the fixpoint expansion settles, before `--dry-run` returns, + * against the fixpoint's own residual (the last round's re-diff). A residual change at a + * path this run just planned to write means the write didn't converge — a planner defect, + * surfaced as `ConfigPullPlanDefectError` rather than a crash, since nothing has been written + * yet. A residual `unmanaged` path instead means the value this run just wrote made itself + * invisible to the projection again; that's a known, if rare, side effect of a cross-path + * config prune (see `applyDisabledSentinels`'s cross-section rule in `project-config.ts`), so + * it's surfaced as an `"unpushable"` warning through the same `plan.warnings` hook rather than + * treated as a defect. */ function configPullDefectAndUnpushableCheck( plan: ConfigPullPlan, @@ -311,12 +261,10 @@ function configPullDefectAndUnpushableCheck( } /** - * Builds the FULL raw, on-disk-shaped document `config pull`'s - * schema-validation gate decodes: `rawDocument` (`remotes` intact, - * pre-`env()`-interpolation — the same shape `applyConfigEdits` edits) with - * `writes`' `documentPath`s applied, plus the new block's `project_id` when - * this plan creates one — mirroring step 13's real `edits` array exactly, so - * what gets validated here is what would actually be written. + * Builds the raw, on-disk-shaped document the schema-validation gate decodes: + * `rawDocument` with the planned `writes`' `documentPath`s applied, plus the new block's + * `project_id` when this plan creates one — so validation runs against what would actually + * be written. */ function configPullValidationDocument( rawDocument: Readonly>, @@ -334,13 +282,9 @@ function configPullValidationDocument( } /** - * Restricts a document to the subtree a `ConfigChange.path` (hosted-config, - * destination-agnostic) is relative to: itself for a root destination, or - * `document.remotes[label]` for a `[remotes.*]` destination — the inverse of - * `documentPathFor` (`pull.plan.ts`), needed because the schema-validation - * gate's failing paths (and the pre-write raw document it looks up an env() - * spelling in) must be read in the SAME namespace `ConfigChange.path`/the - * plan's family-root helpers already use. + * Restricts a document to the subtree a `ConfigChange.path` is relative to: itself for a + * root destination, or `document.remotes[label]` for a `[remotes.*]` destination. The + * inverse of `documentPathFor` (`pull.plan.ts`). */ function configPullChangeRelativeValue( document: unknown, @@ -354,27 +298,12 @@ function configPullChangeRelativeValue( } /** - * A failed {@link decodeCliConfigDocumentForValidationEffect} attempt's own - * `SchemaIssue` paths, converted to `ConfigChange.path`-relative ("change - * path") segments — the SAME destination-agnostic namespace `write.change.path` - * already lives in, regardless of where a write physically lands in the - * document. - * - * `isLabelPrefixed` picks between the TWO shapes a decode's own issue paths - * can take — entirely independent of `destination.kind`, since it is the - * PROJECTION (raw vs. `remoteName`-merged; see - * {@link validateConfigPullPlan}) that determines this, not the - * destination: the RAW/unmerged projection of a REMOTE destination decodes - * the whole `remotes` map through `RemotesSchema` (`disableChecks: true`), - * whose own issue paths start with the map's OWN key — the label itself, not - * the literal word `remotes` — so dropping that one leading segment recovers - * the change-path form; every OTHER case (a root destination's raw - * projection, or ANY destination's `remoteName`-merged projection, which - * decodes the merged document at the schema ROOT) already reports - * change-path-relative paths, nothing to strip. Not a `SchemaError` at all - * (should not happen — this only ever runs against a `CliConfigParseError` - * this same module's own decode calls produced) yields no paths, which - * callers treat as "could not attribute this failure". + * Converts a failed {@link decodeCliConfigDocumentForValidationEffect} attempt's + * `SchemaIssue` paths into `ConfigChange.path`-relative segments. `isLabelPrefixed` strips + * one leading segment for the raw/unmerged projection of a remote destination, whose issue + * paths start with the `remotes` map's own key (the label) rather than already being + * change-path-relative like every other case. Returns no paths for a non-`SchemaError` + * cause, which callers treat as "could not attribute this failure". */ function configPullSchemaIssueChangePaths( cause: CliConfigParseError, @@ -402,18 +331,12 @@ function configPullSchemaIssueChangePaths( } /** - * Groups already-resolved change-paths (`configPullSchemaIssueChangePaths`, - * already filtered by the caller to exclude every PRE-EXISTING failure) by - * `configPullFamilyRootForPath`'s nearest-enclosing-table rule, - * enriching each with its local `env(VAR)` spelling when it has one. - * `relativeValidation`/`relativeRaw` are always the DESTINATION-relative view - * (`configPullChangeRelativeValue`) — the subtree a change-path is - * actually relative to on disk — regardless of which projection (raw or - * `remoteName`-merged) reported the failure: a merged projection's own issue - * paths already arrive change-path-relative (see the sibling function - * above), but the SHAPE this function reads off `document` (an enclosing - * "family" table, a field's local raw spelling) lives at the same - * destination-relative location either way. + * Groups already-resolved change-paths (already filtered by the caller to exclude every + * pre-existing failure) by `configPullFamilyRootForPath`'s nearest-enclosing-table rule, + * enriching each with its local `env(VAR)` spelling when it has one. `relativeValidation`/ + * `relativeRaw` are always the destination-relative view + * ({@link configPullChangeRelativeValue}), regardless of which projection reported the + * failure. */ function configPullFamiliesForChangePaths( changePaths: ReadonlyArray>, @@ -449,15 +372,10 @@ function configPullFamiliesForChangePaths( } /** - * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only ITS - * OWN `CliConfigParseError` failure into a `Result` the caller inspects (the - * schema-validation gate's "did this decode" check) — `CliProjectEnvParseError`/ - * `PlatformError` (a genuinely malformed `.env`/`.env.local`, or a filesystem - * failure reading one) are not decode-ATTRIBUTION failures at all, so they are - * left in the returned Effect's error channel to propagate uncaught, exactly - * like the SAME two failures already do from the real `loadCliConfig` call - * this command's own initial load makes (`makeConfigLoader` only ever catches - * `CliConfigParseError`/`DuplicateRemoteProjectIdError` there too). + * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only its own + * `CliConfigParseError` failure into a `Result`. A genuinely malformed `.env`/`.env.local`, + * or a filesystem failure reading one, is not a decode-attribution failure, so those + * propagate uncaught, matching how the real `loadCliConfig` call already handles them. */ function decodeConfigPullValidation( document: Record, @@ -471,16 +389,12 @@ function decodeConfigPullValidation( /** * The change-path keys ({@link configPathKey}) that already fail - * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` AS IT - * SITS ON DISK RIGHT NOW — before this pull's own writes are projected onto - * it. {@link validateConfigPullPlan} excludes every one of these from - * the families it forms: the file was already in that state, so pull - * attributing the failure to its own plan, dropping a write over it, or - * failing the whole command over it would all be wrong — pulling only ever - * needs to leave the file NO WORSE than it already was. Runs the SAME two - * projections the round-by-round gate below runs (raw, plus - * `remoteName`-merged for a remote destination), so a pre-existing failure - * that only surfaces once a `[remotes.*]` block is SELECTED is exempted too. + * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` as it sits on disk + * right now, before this pull's own writes are projected. {@link validateConfigPullPlan} + * excludes these from the families it forms — pulling should never attribute, drop, or fail + * over a pre-existing problem; it only needs to leave the file no worse than it was. Runs + * both projections (raw, plus `remoteName`-merged for a remote destination) so a failure + * that only surfaces once a `[remotes.*]` block is selected is exempted too. */ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* (input: { readonly rawDocument: Readonly>; @@ -518,38 +432,19 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( return keys; }); -/** Cap on how many times {@link validateConfigPullPlan} drops a family - * and re-validates — die-free: hitting the cap fails the whole command with a - * typed `ConfigPullValidationFailedError` rather than writing (per that - * error's own doc comment, reaching the cap "shouldn't happen", since - * dropping a family always restores a state that loaded before this pull - * ran). */ +/** Cap on how many times {@link validateConfigPullPlan} drops a family and re-validates; + * hitting it fails the command with `ConfigPullValidationFailedError` rather than writing. */ const CONFIG_PULL_VALIDATION_ROUND_CAP = 4; /** - * `config pull`'s schema-validation gate (CLI-2064's live-bug fix, layer - * 2): pull must NEVER write a file the CLI itself cannot load. Before the - * TOCTOU re-read/write, decodes the projected FINAL document (every planned - * write, plus a new block's `project_id`, applied to the raw on-disk - * document) through the real `CliConfigSchema` decode, resolving `env(VAR)` - * EXACTLY as the next `loadCliConfig` call will (`decodeCliConfigDocumentForValidationEffect` - * — process env layered with the project's own `.env`/`.env.local`, not bare - * `process.env`). A `[remotes.*]` destination additionally validates the - * `remoteName`-merged projection — the same overlay a future `loadCliConfig` - * targeting THIS project ref applies before its own checks-enabled decode — - * since a written block can pass the raw/unmerged check (remotes decode with - * business-rule checks disabled) yet still fail once actually selected. - * - * A decode failure whose change-path already failed in `rawDocument` BEFORE - * this pull touched it ({@link configPullPreExistingFailingChangePathKeys}) - * is PRE-EXISTING: never attributed to this plan, never dropped, never a - * reason to fail the command — the file was already in that state, and pull - * leaves it no worse. Only a NEW failing change-path drives the drop below: - * every write under its nearest enclosing family/provider table - * (`dropConfigPullUnvalidatableFamilies`), re-validated, repeating up - * to {@link CONFIG_PULL_VALIDATION_ROUND_CAP} times; if validation - * still fails on a NEW path once nothing more can be dropped, fails the whole - * command (`ConfigPullValidationFailedError`) rather than write. + * `config pull`'s schema-validation gate: decodes the projected final document the way the + * next `loadCliConfig` call will (including a `[remotes.*]` destination's `remoteName`-merged + * projection, since a block can pass the raw check yet still fail once selected), and never + * writes a file the CLI itself couldn't load. A failure already present in + * {@link configPullPreExistingFailingChangePathKeys} is ignored; a new one drops its + * enclosing family ({@link dropConfigPullUnvalidatableFamilies}) and retries, up to + * {@link CONFIG_PULL_VALIDATION_ROUND_CAP} times, else fails the command + * (`ConfigPullValidationFailedError`) instead of writing. */ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { readonly plan: ConfigPullPlan; @@ -638,17 +533,9 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { }); }); -/** Builds the file-load helpers for one `cliSettings.workdir` — a small - * factory rather than a shared closure so both `openConfigPullSource` - * (steps 2-3) and `planConfigPullRun` (step 6's conditional reload) get - * their own, independently testable copy without threading `cliSettings` - * through `ConfigPullInput`. Narrowed to `workdir` + - * `explicitWorkdir` (rather than the full `CommandSettings` shape) since - * that's all `loadLocalConfig` (`config.load.ts`, shared with - * `config diff`/`config push`) needs — it owns the parse/duplicate-remote/ - * missing-file message shapes and the ancestor-search decision - * (`shouldSearchAncestors`); only this family's own tagged error class - * is local. */ +/** Builds the file-load helpers for one `cliSettings.workdir`, narrowed to `workdir` and + * `explicitWorkdir` since that's all `loadLocalConfig` needs; only this family's own tagged + * error class is local. */ function makeConfigLoader(cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean; @@ -667,11 +554,9 @@ function makeConfigLoader(cliSettings: { } /** - * The paired base config load + its exact on-disk text, produced ONLY by - * {@link openConfigPullSource} — never assembled by hand elsewhere, so - * "loaded with NO `[remotes.*]` overlay" and "text read from the SAME path - * immediately after that load" are true by construction, not by caller - * convention. + * The paired base config load and its exact on-disk text, produced only by + * {@link openConfigPullSource} so both properties — no `[remotes.*]` overlay, and text read + * from the same path right after the load — hold by construction, not caller convention. */ export interface ConfigPullSource { readonly loaded: LoadedCliConfig; @@ -679,18 +564,12 @@ export interface ConfigPullSource { } /** - * Opens `config pull`'s base config source (`configPull` steps 2-3): - * loads the local config with NO `[remotes.*]` overlay applied — the overlay - * is keyed by the RESOLVED target ref, applied later inside - * `runConfigPull` step 6 only when block reuse selects it — then takes - * `loaded.rawText` (`@supabase/config`'s own capture of the exact bytes it - * parsed) as this pull's baseline text, rather than reading the file a - * second time: a separate read here would reopen a window for a concurrent - * edit to land BETWEEN the parsed load and that read, silently becoming the - * accepted baseline while the plan below is computed against the (now - * stale) parsed values. The SAME bytes `applyConfigEdits` edits later - * (step 13), and the baseline `runConfigPull` re-reads before writing - * to detect a concurrent edit (step 12). + * Opens `config pull`'s base config source: loads the local config with no `[remotes.*]` + * overlay (applied later, only when block reuse selects an existing block), then uses + * `loaded.rawText` as this pull's baseline text instead of reading the file again — a second + * read would reopen a window for a concurrent edit to land between the parsed load and that + * read, silently becoming the accepted baseline while the plan below is computed against the + * now-stale parsed values. */ export const openConfigPullSource = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -699,10 +578,8 @@ export const openConfigPullSource = Effect.fnUntraced(function* () { const loaded = yield* loadConfig(undefined); if (loaded.rawText === undefined) { - // The loader contract guarantees `rawText` for any file it actually - // parsed off disk — reaching this would mean that contract broke. Fail - // the same way a genuine concurrent edit does, rather than falling back - // to a second read that would reopen the exact race this baseline + // The loader guarantees `rawText` for any file it parsed off disk; treat this like a + // concurrent edit rather than re-reading, which would reopen the race this baseline // exists to close. return yield* new ConfigPullFileChangedError({ message: `${toRelativeConfigPath(loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, @@ -713,14 +590,12 @@ export const openConfigPullSource = Effect.fnUntraced(function* () { }); /** - * Steps 5-8 of `config pull` (plan §1.6's plan/apply split): destination - * resolution, the conditional `[remotes.*]`-overlay reload, the remote fetch, - * the fixpoint-expanded diff/plan, and the planner-defect/schema-validation - * gate. Prints the "Pulling config from …" destination line and the - * "Comparison scope: …" line to stderr exactly once, here — never repeated by - * a caller. Never runs the git dirty guard, never prompts, never writes, and - * never calls `output.success`/`emitOutcome`; the caller decides what to do - * with the returned {@link ConfigPullRunPlan}. + * Resolves the pull destination, reloads with the `[remotes.*]` overlay when block reuse + * selects an existing block, fetches the remote config, and runs the fixpoint-expanded + * diff/plan plus the planner-defect/schema-validation gate. Prints the destination and + * comparison-scope lines to stderr exactly once. Never runs the git dirty guard, prompts, + * writes, or calls `output.success`; the caller decides what to do with the returned + * {@link ConfigPullRunPlan}. */ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPullPlanRequest) { const output = yield* Output; @@ -729,9 +604,6 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul const { ref, branch } = request.target; const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); - // 5. Resolve WHERE this pull writes (root vs. an existing/new - // `[remotes.*]` block) — pure, no network call — then print the - // destination line to stderr BEFORE any network call. const branchLabelCandidate = branch !== undefined && !BRANCH_UUID_PATTERN.test(branch) ? branch : undefined; const scopeResult = resolveConfigPullDestination({ @@ -755,8 +627,7 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul const destination = scopeResult.destination; yield* output.raw(configPullDestinationLine({ projectRef: ref, branch }, destination), "stderr"); - // 6. Reload WITH the `[remotes.*]` overlay only when block reuse selected - // an EXISTING block — a brand-new block has nothing to overlay yet. + // A brand-new block has nothing to overlay yet. let loaded = request.source.loaded; if (destination.kind === "remote" && !destination.created) { loaded = yield* loadConfig(ref); @@ -772,10 +643,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul destination, }; - // 7. Fetch the effective remote config — verbatim from `config diff` - // (ADR 0019 rule 2: `executeRaw` + lenient decode boundary; the caller - // owns the status check, `fromApiProjectConfig`'s lenient decode owns the - // body). + // See ADR 0019 rule 2: `executeRaw` + lenient decode; the caller owns the status check, + // `fromApiProjectConfig`'s lenient decode owns the body. const fetching = output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( @@ -808,9 +677,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul ); yield* fetching?.clear() ?? Effect.void; - // Project the response through CLI-2230's convergence normalizer (ADR - // 0021) and classify — same typed/defect boundary as `config diff` - // (`configProjectConfigTry`, shared across the `config` family). + // Normalizes and classifies the response through the config family's shared + // typed/defect boundary (ADR 0021). const remote = yield* configProjectConfigTry(() => fromApiProjectConfig(responseJson)); const initialChangeSet = yield* configProjectConfigTry(() => diffProjectConfig({ local: loaded, remote }), @@ -822,15 +690,9 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul ); yield* output.raw(configScopeLine(scope), "stderr"); - // 8. Fixpoint-expand the diff (plan §1.9, extended by CLI-2064's live-bug - // fix): projecting a round's writes can un-gate a sibling ADR 0021's - // disabled-provider gates would otherwise have excluded as unmanaged (e.g. - // flipping a disabled SMS provider's `enabled` on un-gates its credential - // siblings) — `expandConfigPullChangeSet` repeats until nothing new - // appears. Plan the fully-expanded writes, check for a planner defect / - // surface unpushable notes against the fixpoint's own residual (unchanged - // from before), then run the schema-validation gate (layer 2): pull must - // never write a file the CLI itself cannot load. + // Projecting a round's writes can un-gate sibling fields ADR 0021 otherwise excludes as + // unmanaged (e.g. flipping a disabled SMS provider's `enabled` on un-gates its credential + // fields), so `expandConfigPullChangeSet` repeats until nothing new appears. const fixpoint = yield* configProjectConfigTry(() => expandConfigPullChangeSet({ initialChangeSet, @@ -857,10 +719,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul format: loaded.format, }); - // `hasBlockToCreate` is why this is `hasWork`, not merely - // `writes.length === 0`: a zero-drift branch target still has WORK to do - // (creating the block), so it must reach the git guard/confirmation like - // any other write (CLI-2064 bug B). + // A zero-drift branch target can still have work to do — creating the block — so it must + // reach the git guard/confirmation like any other write. const hasBlockToCreate = finalPlan.createdTable !== undefined; const hasWork = finalPlan.writes.length > 0 || hasBlockToCreate; @@ -875,11 +735,9 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul }); /** - * Steps 12-13 of `config pull`: the TOCTOU re-read against - * {@link ConfigPullSource.text} (someone may have edited the file - * while the confirmation prompt was on screen), `applyConfigEdits`, and the - * atomic write. No emission — the caller renders the final summary/payload - * once this succeeds. + * The TOCTOU re-read against {@link ConfigPullSource.text} (someone may have edited the file + * while the confirmation prompt was on screen), `applyConfigEdits`, and the atomic write. No + * emission — the caller renders the final summary/payload once this succeeds. */ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { readonly runPlan: ConfigPullRunPlan; @@ -888,8 +746,6 @@ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { const fs = yield* FileSystem.FileSystem; const { plan, context, configFilePath } = input.runPlan; - // 12. Re-read and compare against the step-3 baseline — someone may have - // edited the file while the prompt was on screen. const currentText = yield* fs.readFileString(configFilePath).pipe( Effect.catchTag( "PlatformError", @@ -905,16 +761,11 @@ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { }); } - // 13. Apply and write. When this pull CREATES a new `[remotes.