diff --git a/apps/cli/docs/supabase/config/push.md b/apps/cli/docs/supabase/config/push.md index 859d340ad7..5f912a93e8 100644 --- a/apps/cli/docs/supabase/config/push.md +++ b/apps/cli/docs/supabase/config/push.md @@ -3,3 +3,9 @@ Updates the configurations of a linked Supabase project with the local `supabase/config.toml` file. This command allows you to manage project configuration as code by defining settings locally and then pushing them to your remote project. + +Pass `--project-ref` to push to a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. + +Before anything is pushed, the command reports the project it resolved — its name and ref. When the resolved target is a preview branch that was only _inferred_ (for example after `supabase link `, or via `SUPABASE_PROJECT_ID`) rather than named on this invocation, it says so, names the branch and its parent project where they can be resolved, and asks for confirmation first. Declining — including on an unattended run with no `--yes` and no piped answer — fails the command rather than pushing. Pass `--yes` (or set `SUPABASE_YES`) to skip that confirmation in CI and scripted runs. + +An explicit `--project-ref ` on the same invocation skips the confirmation entirely (the target is still echoed) — naming the branch yourself already counts as confirming it. Because the confirmation reads one line of piped input just like any other prompt, a script piping multiple `y`/`n` answers to an _inferred_ branch target needs one extra leading answer for it, ahead of the per-service prompts it already answers. diff --git a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md index 6c5792a433..6b0b95c28c 100644 --- a/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md @@ -6,14 +6,14 @@ the unified diff and confirm → PATCH/PUT/POST. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, before any network call (parse error aborts, exit 1) | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | -| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per the rules below | -| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) | -| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism) | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME `loadCliConfig` call so a matching `[remotes.]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1) | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | +| Auth email template HTML (`content_path`) | HTML | when `auth.enabled`; paths resolved per the rules below | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the telemetry cache write is skipped (`ensureProjectGroupsCached` — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -29,22 +29,26 @@ No writes to `config.toml`. All Bearer-authenticated. Iterated in this order; a service is skipped (no GET) when its local gate is off. -| # | Service | Method | Path | Success | Notes | -| --- | ----------------------- | ------ | ----------------------------------------------- | ------- | --------------------------------------------- | -| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | raw HTTP; cost map for 1-variant addons | -| 1 | api | GET | `/v1/projects/{ref}/postgrest` | 200 | | -| 1 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only if diff present + kept | -| 2a | db.settings | GET | `/v1/projects/{ref}/config/database/postgres` | 200 | always processed (no gate) | -| 2a | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | | -| 2b | db.network_restrictions | GET | `/v1/projects/{ref}/network-restrictions` | 200 | only if local `enabled` | -| 2b | db.network_restrictions | POST | `/v1/projects/{ref}/network-restrictions/apply` | 201 | | -| 2c | db.ssl_enforcement | GET | `/v1/projects/{ref}/ssl-enforcement` | 200 | only if `[db.ssl_enforcement]` present | -| 2c | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | | -| 3 | auth | GET | `/v1/projects/{ref}/config/auth` | 200 | only if local `auth.enabled` | -| 3 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | MFA phone/webauthn gated by addon cost prompt | -| 4 | storage | GET | `/v1/projects/{ref}/config/storage` | 200 | only if local `storage.enabled` | -| 4 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | | -| 5 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `webhooks.enabled`; no GET/diff | +| # | Service | Method | Path | Success | Notes | +| --- | ------------------------ | ------ | ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--project-ref` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | +| -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--project-ref` is a UUID (CLI-2289); needs no linked project | +| -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--project-ref` already named a branch by name/UUID above. | +| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when a candidate parent is known (from `--project-ref` or `.temp/linked-project.json`); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | +| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | raw HTTP; cost map for 1-variant addons | +| 1 | api | GET | `/v1/projects/{ref}/postgrest` | 200 | | +| 1 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only if diff present + kept | +| 2a | db.settings | GET | `/v1/projects/{ref}/config/database/postgres` | 200 | always processed (no gate) | +| 2a | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | | +| 2b | db.network_restrictions | GET | `/v1/projects/{ref}/network-restrictions` | 200 | only if local `enabled` | +| 2b | db.network_restrictions | POST | `/v1/projects/{ref}/network-restrictions/apply` | 201 | | +| 2c | db.ssl_enforcement | GET | `/v1/projects/{ref}/ssl-enforcement` | 200 | only if `[db.ssl_enforcement]` present | +| 2c | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | | +| 3 | auth | GET | `/v1/projects/{ref}/config/auth` | 200 | only if local `auth.enabled` | +| 3 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | MFA phone/webauthn gated by addon cost prompt | +| 4 | storage | GET | `/v1/projects/{ref}/config/storage` | 200 | only if local `storage.enabled` | +| 4 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | | +| 5 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `webhooks.enabled`; no GET/diff | `UpdateSigningKeys` is **not** called by `config push`. @@ -61,39 +65,89 @@ when its local gate is off. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------ | -| `0` | success, **including** declining a confirmation prompt | -| `1` | malformed `config.toml` | -| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | -| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | -| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | -| `1` | list-addons failure (network or non-200) | -| `1` | any per-service read/update failure (network or unexpected status) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | +| `1` | user declined the branch confirmation gate (cancellation, `LegacyConfigPushCancelledError`) — see Output below | +| `1` | malformed `config.toml` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | +| `1` | invalid `auth.email.*.content_path` (missing/unreadable template file when `auth.enabled`) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | list-addons failure (network or non-200) | +| `1` | any per-service read/update failure (network or unexpected status) | +| `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | ## Output ### `--output-format text` All diagnostics on **stderr**, no stdout. When a `[remotes.]` block matches the -target ref, `Loading config override: [remotes.]` prints first. Then -`Pushing config to project: `, then -per service either `Remote config is up to date.` or -`Updating service with config: `; experimental prints -`Enabling webhooks for project: `. Confirmations render ` [Y/n] ` -(or `<title> [Y/n] y` when `--yes`). +target ref, `Loading config override: [remotes.<name>]` prints first. Then the +target-echo block (CLI-2168) — for a plain project, `Pushing config to project: <name> (<ref>)` +(degrades to the bare `Pushing config to project: <ref>` when no name could be +resolved); for a branch, `Pushing config to branch: <name> (<ref>)` followed, +only when known, by a second line ` Parent project: <name> (<ref>)` (either +half degrades to a bare ref on its own when its name isn't known; the parent +line is omitted entirely when no parent could be determined at all) — this +line always prints for a branch target, whether or not the confirmation gate +below is shown; or, when the target-detection probe couldn't tell at all +(a timeout, a transport failure, or an unexpected status), `Pushing config +to: <ref> (could not determine whether this is a branch or the main +project)` — this shape never gates a confirmation and the push proceeds. +When the target is a branch AND it was resolved IMPLICITLY +(no explicit `--project-ref <name-or-uuid>` this invocation — e.g. a stale +`.temp/project-ref` from an old `link`, or `SUPABASE_PROJECT_ID` pointing +somewhere forgotten), a confirmation gate follows immediately — +`Do you want to push config to branch "<name>" (<ref>)? (skip this check with +--yes) [y/N] ` (bare ref, no quotes, when the name is unknown). An EXPLICIT +`--project-ref <branch-name-or-uuid>` this invocation skips the gate entirely +(same-invocation intent already expressed once) — only the target-echo line +above prints, and the push proceeds immediately. +Declining fails the command (`LegacyConfigPushCancelledError`, exit `1`) — the +rendered text is `context canceled` (`Output.fail`'s standard text-mode +rendering, no `--debug` hint) — before any further network call (not even the +cost-matrix fetch). Unlike this command's other confirmations, this gate's +default is **no**: a non-TTY run with no piped answer, or `--output-format +json`/`stream-json`, declines (and fails) rather than proceeding, unless +`--yes`/`SUPABASE_YES` is set. A plain-project target never shows this prompt. +Then per service either `Remote <X> config is up to date.` or `Updating <X> +service with config: <unified diff>`; experimental prints `Enabling webhooks +for project: <ref>`. The remaining per-service confirmations are unchanged: +`<title> [Y/n] ` (or `<title> [Y/n] y` when `--yes`) — and still exit **0** on +decline, only the branch gate above now fails. ### `--output-format json` / `stream-json` -Per-service diagnostics stay on stderr; prompts auto-confirm (default yes). A -structured summary is emitted on stdout via `output.success("", data)`. +Per-service diagnostics stay on stderr; the branch confirmation gate above +auto-**declines** (and fails) without `--yes` (see above — this gate's default +differs from every other confirmation in this command, which auto-confirm). A +structured summary is emitted on stdout via `output.success("", data)`; a +declined/failed branch gate instead emits this command's standard machine +error envelope (`{_tag: "Error", error: {...}}` in `json` mode, a `{type: +"error", ...}` NDJSON event in `stream-json` mode) with no success payload. `json` mode — one flat object (note the empty `message` field added by -`output.success`): +`output.success`); `is_branch`/`branch`/`parent_project_ref` are additive +(CLI-2168/CLI-2289) — `branch`/`parent_project_ref` are present only when +resolved, and `is_branch` itself is OMITTED entirely (not `false`) for the +"could not determine" target above — an absent key is the honest "unknown" +signal, since asserting `false` would be as misleading as asserting `true`: ```jsonc { "project_ref": "abcdefghijklmnopqrst", + "is_branch": false, + "services": [{ "service": "api", "status": "updated" }], + "message": "", +} +``` + +```jsonc +{ + "project_ref": "bbbbbbbbbbbbbbbbbbbb", + "is_branch": true, + "branch": "feat-x", + "parent_project_ref": "pppppppppppppppppppp", "services": [{ "service": "api", "status": "updated" }], "message": "", } @@ -103,14 +157,21 @@ structured summary is emitted on stdout via `output.success("", data)`. `data` (consumers read `result.data.project_ref`, not `result.project_ref`): ```jsonc -{ "type": "result", "data": { "project_ref": "…", "services": […], "message": "" }, "timestamp": "…" } +{ "type": "result", "data": { "project_ref": "…", "is_branch": false, "services": […], "message": "" }, "timestamp": "…" } ``` `status ∈ "updated" | "up_to_date" | "skipped" | "disabled"`; dotted `service` -keys mirror `config.toml` paths. +keys mirror `config.toml` paths. When the branch gate declines (machine +format without `--yes`), the command fails (exit `1`) with the standard error +envelope in place of the success payload — see above. ## Notes +- **`--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked project** (CLI-2289, the same vocabulary `link`/`config diff` already accept). A value that is exactly 20 lowercase letters is always treated as a ref. A name is resolved against the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID resolves directly and needs no linked project at all. +- **Every invocation detects whether the resolved ref is the linked project, one of its branches, or genuinely undeterminable** (CLI-2168) and always echoes which one before doing anything else — see Output below. When `--project-ref` already named a branch by name/UUID, this is known for free (certain, never re-derived from a live probe); otherwise it's a live `GET /v1/projects/{ref}` probe: 200 is a plain project, 404 confirms a branch, and EVERYTHING else (a TIMEOUT, a transport failure, or any other status) degrades to "unknown" — never "project" (that would skip the confirmation gate for what might genuinely be a branch) and never a hard failure (this probe is diagnostic-only and must never block a push that would otherwise succeed). A confirmed branch's own name/parent are recovered best-effort from `.temp/linked-project.json`/`.temp/project-ref` and a branch-list lookup. A CONFIRMED branch target resolved IMPLICITLY (not via an explicit `--project-ref <name-or-uuid>` this invocation) is gated behind a confirmation before any further network call; a target resolved from an EXPLICIT `--project-ref <name-or-uuid>` this invocation skips that confirmation (same-invocation intent already expressed once); an "unknown" target is never gated at all. The target-echo line always prints regardless of which shape it is. +- **Resolution runs BEFORE the config load**, not after: a `[remotes.<name>]` overlay is merged INSIDE `loadCliConfig` itself before its one full schema decode, and only one decode may ever run per invocation — reloading with a different `projectRef` a second time would either double the load-time deprecation warnings or wrongly reject a base document that's only valid once its matching remote's overlay applies. The accepted tradeoff: a branch name/UUID resolution's network call can fire even when the local `config.toml` turns out to be malformed (this only affects `--project-ref <name-or-uuid>`; a ref-shaped or absent target never needs a network call to resolve, so a malformed config there still aborts with zero requests made, matching this command's behavior before CLI-2168/CLI-2289). +- **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). +- The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. - Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. - Diff bytes use the BurntSushi TOML encoder + anchored diff ports. diff --git a/apps/cli/src/legacy/commands/config/push/push.branch-target.ts b/apps/cli/src/legacy/commands/config/push/push.branch-target.ts new file mode 100644 index 0000000000..e1ca56ef39 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/push/push.branch-target.ts @@ -0,0 +1,247 @@ +import { Effect, FileSystem, Option, Path } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; +import { + LEGACY_BRANCH_LOOKUP_TIMEOUT, + legacyFindBranchName, +} from "../../../shared/legacy-branch-target.ts"; +import { + type LegacyCachedLinkedProject, + legacyParseCachedLinkedProject, +} from "../../../shared/legacy-parent-project-ref.ts"; +import { LEGACY_BRANCH_PROJECT_REF_PATTERN } from "../../../shared/legacy-ref-patterns.ts"; +import { legacyReadProjectRefFile, legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; + +/** + * What `ref` actually is, resolved for `config push`'s target-echo + branch + * gate (CLI-2168): + * + * - `"project"` — `ref` is the linked project itself. `name` is present + * when the live probe below found it. + * - `"branch"` — `ref` is CONFIRMED to be a preview branch (an explicit + * `--project-ref <name-or-uuid>`, or a live 404). `parentRef`/ + * `parentName`/`branch` are each present only when they could actually + * be determined — "confirmed branch, nothing else known" is exactly this + * shape with all three absent. + * - `"unknown"` — the live probe couldn't tell (a timeout, a transport + * failure, or a non-200/404 status, e.g. a scoped token that can write + * service config but can't read the project record). Never asserted as + * a branch: an uncertain outcome must not gate a plain, everyday push + * behind a confirmation that then auto-declines (and FAILS — see + * `push.handler.ts`) an unattended run over nothing more than network + * jitter. Never asserted as a project either — the target-echo line + * says plainly that it doesn't know. + */ +export type LegacyConfigPushTarget = + | { readonly kind: "project"; readonly ref: string; readonly name?: string } + | { + readonly kind: "branch"; + readonly ref: string; + readonly parentRef?: string; + readonly parentName?: string; + readonly branch?: string; + } + | { readonly kind: "unknown"; readonly ref: string }; + +/** + * A branch name/UUID the caller already resolved `ref` from (CLI-2289's + * `--project-ref <name-or-uuid>` path) — this makes the caller's knowledge + * that `ref` is a branch DEFINITIVE, so {@link legacyResolveConfigPushTarget} + * never runs the live `getProject` probe for it: + * + * - `"name"` — both fields were resolved eagerly, nothing more to recover. + * - `"uuid"` — certain to be a branch, but a UUID carries no display name + * and never forces its parent (`GET /v1/branches/{id}` alone resolves + * it), so it runs the SAME best-effort cache recovery a live 404 does, + * to fill in whatever it can. A discriminated union rather than an + * all-optional shape: the only two states a real caller ever produces + * are "both known" and "neither known" — a partial state (a parent + * without a name, or vice versa) is not a shape this resolver needs to + * handle, so it doesn't exist to be handled incorrectly. + */ +export type LegacyConfigPushKnownBranch = + | { readonly kind: "name"; readonly branchName: string; readonly parentRef: string } + | { readonly kind: "uuid" }; + +/** `V1GetProjectOutput.name`/a branch's `name` are unconstrained strings — an + * empty (or empty-after-sanitization-adjacent) live value must render as "no + * name available", matching `legacyParseCachedLinkedProject`'s own + * empty-filtering convention for cached names. */ +function normalizeApiName(name: string | undefined): string | undefined { + return name !== undefined && name.length > 0 ? name : undefined; +} + +function isNotFound(cause: unknown): boolean { + return ( + HttpClientError.isHttpClientError(cause) && + cause.response !== undefined && + cause.response.status === 404 + ); +} + +/** + * Resolves what `ref` actually is, so `config push` can tell the user + * whether they're pushing to the linked project or one of its branches + * (CLI-2168), and so a branch push can be gated behind confirmation. NEVER + * FAILS — matching `legacyFindBranchName`'s own best-effort contract, this + * probe is diagnostic-only and must never abort a push that would otherwise + * succeed. + * + * - `opts.knownBranch?.kind === "name"`: no probe, no recovery — just + * enrich the parent's NAME from `.temp/linked-project.json` when its + * `ref` matches `parentRef`. + * - `opts.knownBranch?.kind === "uuid"`, or a live 404: `ref` is + * CONFIRMED a branch; run the shared best-effort recovery below. + * - Otherwise (`opts.knownBranch` absent): `GET /v1/projects/{ref}`, + * bounded at {@link LEGACY_BRANCH_LOOKUP_TIMEOUT} and wrapped in a + * `"Checking project..."` task. A 200 is a plain project. A TIMEOUT, a + * transport failure, or any status other than 200/404 is `"unknown"` — + * the task is marked failed (this diagnostic step genuinely didn't + * complete), but the push itself is never blocked by it. A 404 confirms + * a branch and falls into the same recovery as the `"uuid"` case. + * + * Shared best-effort recovery (a confirmed branch whose name/parent aren't + * fully known yet): + * 1. Read + parse `.temp/linked-project.json` — `cached`. No + * ref-pattern-valid, non-self-referential `cached.ref` → the bare + * `{ kind: "branch", ref }` shape, no further filesystem or API calls. + * 2. Otherwise, read `.temp/project-ref` — `fileRef` — and best-effort + * look up the branch's own name among the candidate parent's branches + * ({@link legacyFindBranchName}). + * 3. The branch-list lookup positively confirming the parent, OR `ref` + * being literally what `.temp/project-ref` currently holds — + * inheriting that file's own link-completed invariant (the same + * reasoning `legacy-linked-state.ts` documents for its own analogous + * case, restated here for a caller-supplied ref instead of a + * self-resolved one) — is what lets the parent claim stand. Neither → + * the bare `{ kind: "branch", ref }` shape, no parent claim at all. + */ +export function legacyResolveConfigPushTarget( + ref: string, + opts: { readonly knownBranch?: LegacyConfigPushKnownBranch }, +): Effect.Effect< + LegacyConfigPushTarget, + never, + LegacyPlatformApi | LegacyCliSettings | FileSystem.FileSystem | Path.Path | Output +> { + return Effect.gen(function* () { + const cliSettings = yield* LegacyCliSettings; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const linkedProjectCachePath = legacyTempPaths(path, cliSettings.workdir).linkedProjectCache; + const readCachedParent = fs.readFileString(linkedProjectCachePath).pipe( + Effect.map(legacyParseCachedLinkedProject), + Effect.orElseSucceed(() => Option.none<LegacyCachedLinkedProject>()), + ); + + // Cheap path (a NAME target, CLI-2289): both fields already known — no + // probe, no best-effort recovery, just enrich the parent's NAME from + // cache when it happens to match. + if (opts.knownBranch?.kind === "name") { + const { branchName, parentRef } = opts.knownBranch; + const cached = yield* readCachedParent; + const parentName = + Option.isSome(cached) && cached.value.ref === parentRef ? cached.value.name : undefined; + return { + kind: "branch", + ref, + parentRef, + ...(parentName === undefined ? {} : { parentName }), + branch: branchName, + }; + } + + // `opts.knownBranch?.kind === "uuid"` means the caller already knows FOR + // CERTAIN `ref` is a branch: skip the live probe entirely. Otherwise, + // run the probe — its only two "certain" outcomes are 200 (return + // immediately) and 404 (fall through to the shared recovery below). + if (opts.knownBranch === undefined) { + const api = yield* LegacyPlatformApi; + const output = yield* Output; + const probing = + output.format === "text" ? yield* output.task("Checking project...") : undefined; + + type ProbeOutcome = + | { readonly kind: "project"; readonly name: string | undefined } + | { readonly kind: "branch" } + | { readonly kind: "unknown" }; + + const probe: ProbeOutcome = yield* Effect.timeoutOrElse( + api.v1.getProject({ ref }).pipe( + Effect.map((project): ProbeOutcome => ({ + kind: "project", + name: normalizeApiName(project.name), + })), + Effect.catch((cause) => + Effect.succeed<ProbeOutcome>( + isNotFound(cause) ? { kind: "branch" } : { kind: "unknown" }, + ), + ), + ), + { + duration: LEGACY_BRANCH_LOOKUP_TIMEOUT, + orElse: () => Effect.succeed<ProbeOutcome>({ kind: "unknown" }), + }, + ); + // A definitive answer (200 or 404) clears the task normally; an + // uncertain one (timeout, transport failure, or an unexpected status) + // marks it failed — the diagnostic step genuinely didn't complete, + // even though the push itself proceeds regardless. + yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear()) ?? Effect.void; + + if (probe.kind === "project") { + return { kind: "project", ref, ...(probe.name === undefined ? {} : { name: probe.name }) }; + } + if (probe.kind === "unknown") { + return { kind: "unknown", ref }; + } + // probe.kind === "branch" (404) — fall into the shared recovery + // below, same as a CONFIRMED-but-incomplete `knownBranch` (a UUID + // target). + } + + // Shared recovery — a CONFIRMED branch (a UUID target, or a live 404) + // whose name/parent aren't known yet. Reads `.temp/project-ref` only + // once there's an actual cache candidate to correlate it against, so + // the common "cache absent" degradation path never touches the + // filesystem a second time. + const cached = yield* readCachedParent; + const candidateParentRef = + Option.isSome(cached) && + cached.value.ref !== ref && + LEGACY_BRANCH_PROJECT_REF_PATTERN.test(cached.value.ref) + ? cached.value.ref + : undefined; + if (candidateParentRef === undefined) { + return { kind: "branch", ref }; + } + + const fileRef = yield* legacyReadProjectRefFile(fs, path, cliSettings.workdir).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + const branchName = normalizeApiName( + yield* legacyFindBranchName(candidateParentRef, ref, { + spinnerLabel: "Checking branch name...", + }), + ); + const trusted = branchName !== undefined || (Option.isSome(fileRef) && fileRef.value === ref); + if (!trusted) { + return { kind: "branch", ref }; + } + + const parentName = + Option.isSome(cached) && cached.value.ref === candidateParentRef + ? cached.value.name + : undefined; + return { + kind: "branch", + ref, + parentRef: candidateParentRef, + ...(parentName === undefined ? {} : { parentName }), + ...(branchName === undefined ? {} : { branch: branchName }), + }; + }); +} diff --git a/apps/cli/src/legacy/commands/config/push/push.command.ts b/apps/cli/src/legacy/commands/config/push/push.command.ts index 9fee2801b2..d0cfa4b4a4 100644 --- a/apps/cli/src/legacy/commands/config/push/push.command.ts +++ b/apps/cli/src/legacy/commands/config/push/push.command.ts @@ -11,8 +11,13 @@ import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-comm import { legacyConfigPush } from "./push.handler.ts"; const config = { + // `link`'s settled vocabulary (CLI-2167/CLI-2289): one flag that accepts + // either a project ref or a branch of the linked project — no separate + // `--target`. projectRef: Flag.string("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), + Flag.withDescription( + "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", + ), Flag.optional, ), } as const; @@ -39,7 +44,7 @@ export const legacyConfigPushHandler = (flags: LegacyConfigPushFlags) => ); export const legacyConfigPushCommand = Command.make("push", config).pipe( - Command.withDescription("Pushes local config.toml to the linked project."), + Command.withDescription("Pushes local config.toml to the linked project or one of its branches."), Command.withShortDescription("Push local config to linked project"), Command.withExamples([ { @@ -50,6 +55,10 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe( command: "supabase config push --project-ref abcdefghijklmnopqrst", description: "Push local config to a specific project", }, + { + command: "supabase config push --project-ref staging", + description: "Push local config to the 'staging' branch", + }, ]), Command.withHandler(legacyConfigPushHandler), Command.provide(Layer.mergeAll(legacyManagementApiRuntimeLayer(["config", "push"]), stdinLayer)), diff --git a/apps/cli/src/legacy/commands/config/push/push.errors.ts b/apps/cli/src/legacy/commands/config/push/push.errors.ts index 20b704e69b..3306020f47 100644 --- a/apps/cli/src/legacy/commands/config/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/config/push/push.errors.ts @@ -52,6 +52,110 @@ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( } } +// --- branch/UUID resolution (CLI-2289) -------------------------------------- +// +// `--project-ref` accepts a project ref, or the name (or UUID) of one of its +// branches — mirrors `config diff`'s own error set 1:1, under push's own +// names (`diff.errors.ts`). + +/** `--project-ref` named a branch the parent project does not have. */ +export class LegacyConfigPushBranchNotFoundError extends Data.TaggedError( + "LegacyConfigPushBranchNotFoundError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * `--project-ref` named a branch (by name), but no project is linked to + * search for branches under — none of `SUPABASE_PROJECT_ID`, + * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` + * yielded a candidate. + */ +export class LegacyConfigPushBranchNotLinkedError extends Data.TaggedError( + "LegacyConfigPushBranchNotLinkedError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} + +/** + * `--project-ref` named a branch (by name), and a parent-project candidate + * exists but is not ref-shaped — corrupt or stale linked state. + */ +export class LegacyConfigPushParentRefInvalidError extends Data.TaggedError( + "LegacyConfigPushParentRefInvalidError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} + +/** + * The resolved branch has no project ref yet (still provisioning) — guards + * against an empty/placeholder ref reaching a push target. + */ +export class LegacyConfigPushBranchNotReadyError extends Data.TaggedError( + "LegacyConfigPushBranchNotReadyError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; + } +} + +export class LegacyConfigPushBranchResolveNetworkError extends Data.TaggedError( + "LegacyConfigPushBranchResolveNetworkError", +)<DecodableNetworkErrorArgs> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigPushBranchResolveStatusError extends Data.TaggedError( + "LegacyConfigPushBranchResolveStatusError", +)<StatusErrorArgs> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} + +// --- live branch detection (CLI-2168) --------------------------------------- +// +// The `getProject` probe that tells the user whether `ref` is the linked +// project or one of its branches is entirely best-effort: a 404 is the +// branch signal (handled by `legacyClassifyProjectLookupError`), and any +// OTHER outcome — a timeout, a transport failure, or a non-200/404 status +// (e.g. a scoped token that can write service config but can't read the +// project record) — degrades to an uncertain target rather than aborting the +// push. There is deliberately no error class for this probe: it never fails +// the command. + +// --- branch confirmation gate (CLI-2168) ------------------------------------ + +/** The user declined the branch confirmation gate. Mirrors `projects + * delete`/`db reset`'s identical top-level "are you sure" cancellation + * shape — declining now FAILS (exit 1), matching every other top-level + * confirmation gate in this codebase; the per-service `keep()` prompts below + * are unrelated and still exit 0 on decline. `suggestion` always names the + * `--yes`/`SUPABASE_YES` escape hatch — the interactive prompt label already + * carries an inline hint, but a machine-mode or non-TTY decline never renders + * that label at all (`legacyPromptYesNo` returns the default silently), so + * this is the only place those callers see it. */ +export class LegacyConfigPushCancelledError extends Data.TaggedError( + "LegacyConfigPushCancelledError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} + // --- cost matrix (list addons) --------------------------------------------- export class LegacyConfigPushListAddonsNetworkError extends Data.TaggedError( diff --git a/apps/cli/src/legacy/commands/config/push/push.format.ts b/apps/cli/src/legacy/commands/config/push/push.format.ts new file mode 100644 index 0000000000..6bb91d2d65 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/push/push.format.ts @@ -0,0 +1,76 @@ +import { + legacyFormatNamedRef, + legacySanitizeInlineName, +} from "../../../shared/legacy-http-errors.ts"; +import type { LegacyConfigPushTarget } from "./push.branch-target.ts"; + +/** + * Pure formatters and payload builders for `config push`'s target-echo and + * branch confirmation (CLI-2168) — no Effect, no services, unit-testable in + * isolation. Every interpolated ref/name goes through `legacyFormatNamedRef` + * (`legacySanitizeInlineName` underneath), so an API-provided branch/project + * name can't inject ANSI/OSC/newline controls into the terminal. + */ + +/** + * The target-echo block, printed to stderr before any further network call. + * Only the NO-NAME-AVAILABLE degradation shape — a plain project whose name + * could not be resolved — stays byte-identical to the pre-CLI-2168 + * `Pushing config to project: <ref>` text (existing tests pin exactly that + * shape). The plain-project SUCCESS path text is NOT byte-identical to the + * old behavior: it now also shows the resolved name whenever one is + * available, which for a real project is always, since `name` is a required + * API field. + */ +export function legacyConfigPushTargetLines(target: LegacyConfigPushTarget): string { + if (target.kind === "project") { + return `Pushing config to project: ${legacyFormatNamedRef(target.name, target.ref)}\n`; + } + if (target.kind === "unknown") { + return `Pushing config to: ${legacySanitizeInlineName(target.ref)} (could not determine whether this is a branch or the main project)\n`; + } + + const lines: Array<string> = [ + `Pushing config to branch: ${legacyFormatNamedRef(target.branch, target.ref)}`, + ]; + if (target.parentRef !== undefined) { + lines.push(` Parent project: ${legacyFormatNamedRef(target.parentName, target.parentRef)}`); + } + return `${lines.join("\n")}\n`; +} + +/** + * The confirmation-prompt label gating a branch push (CLI-2168). Ends with a + * self-serve hint (`--yes`) so a CI/agent log reading the declined prompt can + * fix the invocation without digging through docs. + */ +export function legacyConfigPushBranchPromptLabel( + target: LegacyConfigPushTarget & { readonly kind: "branch" }, +): string { + const ref = legacySanitizeInlineName(target.ref); + const hint = " (skip this check with --yes)"; + return target.branch === undefined + ? `Do you want to push config to branch ${ref}?${hint}` + : `Do you want to push config to branch "${legacySanitizeInlineName(target.branch)}" (${ref})?${hint}`; +} + +/** Additive machine-payload fields describing the resolved target + * (CLI-2168/CLI-2289). `is_branch` is omitted (not `false`) when the target + * couldn't be determined at all — asserting `false` would be as dishonest as + * asserting `true`; an absent key is the correct "we don't know" signal. */ +export function legacyConfigPushPayloadFields(target: LegacyConfigPushTarget): { + readonly is_branch?: boolean; + readonly branch?: string; + readonly parent_project_ref?: string; +} { + if (target.kind === "unknown") { + return {}; + } + return { + is_branch: target.kind === "branch", + ...(target.kind === "branch" && target.branch !== undefined ? { branch: target.branch } : {}), + ...(target.kind === "branch" && target.parentRef !== undefined + ? { parent_project_ref: target.parentRef } + : {}), + }; +} diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 15dbd12eb2..26f609a139 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,22 +1,33 @@ import { dirname } from "node:path"; import { findCliProjectRoot } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; -import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyAssertDecryptableSecrets, legacyLoadProjectEnv, } from "../../../shared/legacy-db-config.toml-read.ts"; -import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { + legacyParentNotLinkedMessage, + legacyParentRefInvalidMessage, + legacyParentRefTypoHint, + legacyResolveLinkedParentRef, +} from "../../../shared/legacy-parent-project-ref.ts"; +import { LEGACY_BRANCH_UUID_PATTERN } from "../../../shared/legacy-ref-patterns.ts"; +import { + legacySanitizeInlineName, + mapLegacyHttpError, +} from "../../../shared/legacy-http-errors.ts"; import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { legacyCollectDotenvPrivateKeys } from "../../../shared/legacy-vault-decrypt.ts"; +import { legacyResolveConfigTarget } from "../config.target.ts"; import { apiSubsetFromConfig, apiToUpdateBody, diffApiWithRemote } from "./config-sync/api.sync.ts"; import { applyRemoteAuthConfig, @@ -47,6 +58,15 @@ import { storageToUpdateBody, } from "./config-sync/storage.sync.ts"; import { loadAuthEmailContent } from "./config-sync/config-sync.auth-email-content.ts"; +import { + legacyConfigPushBranchPromptLabel, + legacyConfigPushPayloadFields, + legacyConfigPushTargetLines, +} from "./push.format.ts"; +import { + type LegacyConfigPushKnownBranch, + legacyResolveConfigPushTarget, +} from "./push.branch-target.ts"; import { getCostMatrix } from "./push.cost-matrix.ts"; import { legacyPresenceIn } from "./push.raw-presence.ts"; import { @@ -58,6 +78,12 @@ import { LegacyConfigPushAuthReadStatusError, LegacyConfigPushAuthUpdateNetworkError, LegacyConfigPushAuthUpdateStatusError, + LegacyConfigPushBranchNotFoundError, + LegacyConfigPushBranchNotLinkedError, + LegacyConfigPushBranchNotReadyError, + LegacyConfigPushBranchResolveNetworkError, + LegacyConfigPushBranchResolveStatusError, + LegacyConfigPushCancelledError, LegacyConfigPushDbReadNetworkError, LegacyConfigPushDbReadStatusError, LegacyConfigPushDbUpdateNetworkError, @@ -69,6 +95,7 @@ import { LegacyConfigPushNetworkRestrictionsReadStatusError, LegacyConfigPushNetworkRestrictionsUpdateNetworkError, LegacyConfigPushNetworkRestrictionsUpdateStatusError, + LegacyConfigPushParentRefInvalidError, LegacyConfigPushSslEnforcementReadNetworkError, LegacyConfigPushSslEnforcementReadStatusError, LegacyConfigPushSslEnforcementUpdateNetworkError, @@ -83,12 +110,37 @@ import type { LegacyConfigPushServiceResult } from "./push.types.ts"; const readStatusMessage = (status: number, body: string) => `unexpected status ${status}: ${body}`; +const mapPushBranchResolveError = mapLegacyHttpError({ + networkError: LegacyConfigPushBranchResolveNetworkError, + statusError: LegacyConfigPushBranchResolveStatusError, + networkMessage: (cause) => `failed to resolve branch: ${cause}`, + statusMessage: readStatusMessage, +}); + +/** Error construction for `legacyResolveConfigTarget` (`../config.target.ts`, + * shared with `config diff`/`config pull`), keeping `config push`'s own + * tagged error classes and message wording. */ +const configTargetErrors = { + notLinked: (target: string) => + new LegacyConfigPushBranchNotLinkedError({ message: legacyParentNotLinkedMessage(target) }), + parentRefInvalid: (target: string) => + new LegacyConfigPushParentRefInvalidError({ message: legacyParentRefInvalidMessage(target) }), + branchNotFound: (target: string) => + new LegacyConfigPushBranchNotFoundError({ + message: `Branch "${legacySanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.${legacyParentRefTypoHint(target)}`, + }), + branchNotReady: (target: string) => + new LegacyConfigPushBranchNotReadyError({ + message: `Branch "${legacySanitizeInlineName(target)}" has no project ref yet. Wait for it to finish provisioning, then retry.`, + }), + mapResolveError: mapPushBranchResolveError, +}; + export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( flags: LegacyConfigPushFlags, ) { const output = yield* Output; const api = yield* LegacyPlatformApi; - const resolver = yield* LegacyProjectRefResolver; const cliSettings = yield* LegacyCliSettings; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; @@ -122,24 +174,21 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const secretEnvLookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; - const ref = yield* resolver.resolve(flags.projectRef); + // `--project-ref` accepts a project ref, or the name (or UUID) of a branch + // of the linked project — `link`'s/`config diff`'s settled vocabulary + // (CLI-2167/CLI-2289). An empty `--project-ref` value is absent, mirroring + // the resolver's own rule. + const requestedRef = Option.filter(flags.projectRef, (value) => value.length > 0); - yield* Effect.gen(function* () { - // 1. Load config.toml (TOML parse error aborts before any network call). - // - // NOTE (CLI-1489): `config push` needs the fully decoded config (every - // service subset), so it uses `loadCliConfig` rather than the tolerant - // `legacy-db-config.toml-read.ts` subtree reader. `loadCliConfig` raises - // `CliConfigParseError` on `env(...)` refs over numeric/bool fields, - // which Go resolves transparently. Switch to the fixed decoder once - // CLI-1489 lands; until then this is the conscious tradeoff for this command. - // Pass `ref` so a matching `[remotes.*]` block is merged over the base - // config before decode. A duplicate `project_id` across remotes surfaces - // an established error message. - const loaded = yield* loadCliConfig(cliSettings.workdir, { - projectRef: ref, - goViperCompat: true, - }).pipe( + // Written once ref resolution succeeds, so the linked-project cache + // finalizer below only fires for invocations that got that far — mirrors + // `diff.handler.ts`'s `resolvedRef` pattern (Legacy Shell Invariant #1): + // every failure path from here on, including branch/UUID resolution, + // stays inside this file's single `Effect.ensuring`-wrapped block below. + let resolvedRef: string | undefined; + + const loadPushConfig = (projectRef: string | undefined) => + loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }).pipe( Effect.catchTag( "CliConfigParseError", (cause) => @@ -151,20 +200,63 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( "DuplicateRemoteProjectIdError", (cause) => new LegacyConfigPushLoadConfigError({ message: cause.message }), ), + Effect.flatMap((loaded) => + loaded === null + ? Effect.fail( + new LegacyConfigPushLoadConfigError({ + message: "failed to read supabase/config.toml: file not found", + }), + ) + : Effect.succeed(loaded), + ), ); - if (loaded === null) { - return yield* new LegacyConfigPushLoadConfigError({ - message: "failed to read supabase/config.toml: file not found", - }); - } - // Printed from inside config load, before any command output. + + yield* Effect.gen(function* () { + // 1. Resolve the push target. `--project-ref` accepts a project ref, or + // the name (or UUID) of a branch of the linked project (CLI-2167/CLI-2289) — + // `legacyResolveConfigTarget` (`../config.target.ts`, Hoist Before You + // Duplicate, shared with `config diff`/`config pull`). This is ALSO + // where `resolvedRef` is set, so every one of the shared resolver's + // failure paths (not linked, invalid parent, not found, not ready, + // network/status) still flushes telemetry and, once a ref is known, + // writes the linked-project cache. + // + // Deliberately runs BEFORE the config load below (unlike `config diff`, + // whose read-only contract makes a load-before-resolve split safe): a + // `[remotes.<name>]` overlay is merged INSIDE `loadCliConfig` itself + // (`applyRemoteOverride`, driven by `projectRef`) before its one full + // schema decode, and only ONE decode may ever run — a base document + // that's schema-invalid without its overlay must never be evaluated on + // its own, or a config that's only valid once the matching remote + // applies would be wrongly rejected (and, on a real remote match, the + // deprecation/SMTP load-time warnings would fire twice). A branch + // name/UUID resolution may therefore cost a network round trip before a + // malformed `config.toml` is caught — an accepted, narrow tradeoff + // (matches this command's own pre-CLI-2168 behavior, which always + // resolved before loading). + const { ref, branch } = yield* legacyResolveConfigTarget(requestedRef, configTargetErrors); + resolvedRef = ref; + + // 2. Load config.toml with the resolved ref (TOML parse error aborts + // before any further work, exit 1). A matching `[remotes.<name>]` + // block's overlay (ADR 0018) is merged before decode in the SAME call — + // see the note above. A duplicate `project_id` across remotes surfaces + // an established error message. + // + // NOTE (CLI-1489): `config push` needs the fully decoded config (every + // service subset), so it uses `loadCliConfig` rather than the tolerant + // `legacy-db-config.toml-read.ts` subtree reader. `loadCliConfig` raises + // `CliConfigParseError` on `env(...)` refs over numeric/bool fields, + // which Go resolves transparently. Switch to the fixed decoder once + // CLI-1489 lands; until then this is the conscious tradeoff for this command. + const loaded = yield* loadPushConfig(ref); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } const projectId = ref; const config = loaded.config; - // 1b. Assert every `config.Secret`-typed `encrypted:` value in the + // 3b. Assert every `config.Secret`-typed `encrypted:` value in the // document (not just auth.*) can be decrypted — this must run before the // cost matrix is fetched or any service is touched. An undecryptable // secret anywhere in the document (even one `config push` never itself @@ -212,10 +304,62 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( }) : { template: {}, notification: {} }; - // 2. Cost matrix (drives cost-aware prompts). - const cost = yield* getCostMatrix(ref); + // 4. Determine the push target (plain project vs. branch vs. unknown) + // and, for a CONFIRMED branch, gate the push behind an explicit + // confirmation before any further network call — including the cost + // matrix below (CLI-2168). A target resolved from an EXPLICIT + // `--project-ref <name-or-uuid>` this invocation (`branch`, from the + // shared resolver above) skips the prompt: the user already expressed + // same-invocation intent, so re-confirming the exact string they just + // typed is friction with no safety benefit. The target-echo line below + // always prints regardless of kind. + // + // `legacyResolveConfigTarget` (shared with `config diff`/`config pull`, + // neither of which needs a branch's PARENT ref) returns only the raw + // `branch` string the user named, not its resolved parent. A UUID + // target genuinely has no parent to give (`GET /v1/branches/{id}` + // resolves it alone) — `{kind: "uuid"}`. A NAME target's parent WAS + // resolved internally to look it up, just not returned; re-deriving it + // here via `legacyResolveLinkedParentRef()` is a second LOCAL-ONLY read + // (env/cache/file, no network) of the exact same chain that just + // resolved moments ago, so it can only disagree if something rewrote + // the linked state mid-command — safe to treat as unreachable. + let knownBranch: LegacyConfigPushKnownBranch | undefined; + if (branch !== undefined) { + if (LEGACY_BRANCH_UUID_PATTERN.test(branch)) { + knownBranch = { kind: "uuid" }; + } else { + const parent = yield* legacyResolveLinkedParentRef(); + knownBranch = + parent.kind === "resolved" + ? { kind: "name", branchName: branch, parentRef: parent.ref } + : { kind: "uuid" }; + } + } + const target = yield* legacyResolveConfigPushTarget(ref, { knownBranch }); + yield* output.raw(legacyConfigPushTargetLines(target), "stderr"); + if (target.kind === "branch" && knownBranch === undefined) { + const proceed = yield* legacyPromptYesNo( + output, + yes, + legacyConfigPushBranchPromptLabel(target), + // Deliberately `false` (unlike this file's other prompts, which + // default `true`): an unattended run (CI, an agent, a script) + // without `--yes` must safely decline a branch mutation rather than + // silently proceed. `--yes`/`SUPABASE_YES` (`yes`, resolved above) + // is the intended override. + false, + ); + if (!proceed) { + return yield* new LegacyConfigPushCancelledError({ + message: CONTEXT_CANCELED_MESSAGE, + suggestion: "Pass --yes (or set SUPABASE_YES) to push to a branch without confirmation.", + }); + } + } - yield* output.raw(`Pushing config to project: ${projectId}\n`, "stderr"); + // 5. Cost matrix (drives cost-aware prompts). + const cost = yield* getCostMatrix(ref); // keep(name): the shared confirmation-prompt helper handles all modes, // including scanning piped stdin on a non-TTY before falling back to @@ -232,7 +376,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const services: Array<LegacyConfigPushServiceResult> = []; - // 3a. api + // 6a. api { const local = apiSubsetFromConfig(config); const remote = yield* api.v1.getPostgrestServiceConfig({ ref }).pipe( @@ -269,7 +413,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3b. db.settings (no gate — always processed) + // 6b. db.settings (no gate — always processed) { const local = dbSettingsFromConfig(config); const response = yield* api.v1.getPostgresConfig({ ref }).pipe( @@ -309,7 +453,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3c. db.network_restrictions (gated on local enabled) + // 6c. db.network_restrictions (gated on local enabled) { const local = networkRestrictionsFromConfig(config); if (!local.enabled) { @@ -353,7 +497,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3d. db.ssl_enforcement (only when locally configured) + // 6d. db.ssl_enforcement (only when locally configured) { const local = sslEnforcementFromConfig(config, presence.sslEnforcement); if (local === undefined) { @@ -396,7 +540,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3e. auth (gated on local enabled; MFA addon cost filter) + // 6e. auth (gated on local enabled; MFA addon cost filter) { if (!authEnabled(config)) { services.push({ service: "auth", status: "disabled" }); @@ -413,7 +557,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( ); // `dotenvPrivateKeys` decrypts any `encrypted:` auth secret before it's // hashed/copied into the update body. The document-wide check above - // (step 1b) already scanned every `config.Secret` path — including every + // (step 3b) already scanned every `config.Secret` path — including every // field `authSubsetFromConfig` reads — and would have aborted by now if // any were undecryptable, so the decrypt calls inside it are unreachable // failure paths here, not a real branch to guard with `Effect.try`. @@ -457,7 +601,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3f. storage (gated on local enabled) + // 6f. storage (gated on local enabled) { const local = storageSubsetFromConfig(config, { imageTransformation: presence.imageTransformation, @@ -501,7 +645,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 3g. experimental.webhooks (no GET / diff) + // 6g. experimental.webhooks (no GET / diff) { if (!experimentalWebhooksEnabled(config)) { services.push({ service: "experimental.webhooks", status: "disabled" }); @@ -526,9 +670,20 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } } - // 4. Machine-readable summary (Go has none; text mode emits nothing extra). + // 7. Machine-readable summary (Go has none; text mode emits nothing extra). if (output.format !== "text") { - yield* output.success("", { project_ref: projectId, services }); + yield* output.success("", { + project_ref: projectId, + ...legacyConfigPushPayloadFields(target), + services, + }); } - }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 3558b1a532..5279b8d734 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -70,6 +70,89 @@ const POSTGREST_DISABLED = { db_pool_acquisition_timeout: null, }; +// Schema-valid `getProject` fixture (CLI-2168's live target-detection probe). +// `name` is distinguishable in assertions so the target-echo text can be +// told apart from a bare-ref fallback. +const TEST_PROJECT = { + id: "abcdefghijklmnopqrst", + ref: "abcdefghijklmnopqrst", + name: "Test Project", + organization_id: "org_test", + organization_slug: "test-org", + status: "ACTIVE_HEALTHY", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + database: { + host: "db.example.co", + version: "15.1.0.117", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +function writeLinkedProjectRefFile(ref: string): void { + const dir = join(tempRoot.current, "supabase", ".temp"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "project-ref"), ref); +} + +function writeLinkedProjectCacheFile(json: Record<string, unknown>): void { + const dir = join(tempRoot.current, "supabase", ".temp"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "linked-project.json"), JSON.stringify(json)); +} + +// CLI-2168/CLI-2289 branch-target fixtures — every ref below is exactly 20 +// lowercase letters (LEGACY_BRANCH_PROJECT_REF_PATTERN), distinct from +// TEST_PROJECT.ref and from each other, so the same test file can model a +// branch, its parent, and an unrelated project simultaneously. +const BRANCH_REF = "cccccccccccccccccccc"; +const PARENT_REF = "pppppppppppppppppppp"; +const OTHER_PARENT_REF = "qqqqqqqqqqqqqqqqqqqq"; +const PROBE_REF = "zzzzzzzzzzzzzzzzzzzz"; +const UUID_TARGET_REF = "rrrrrrrrrrrrrrrrrrrr"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; + +/** `V1ListAllBranchesOutput` item — the best-effort branch-name lookup + * (`legacyFindBranchName`) matches on `project_ref`. */ +const BRANCH_LIST_ITEM = { + id: "22222222-2222-4222-8222-222222222222", + name: "feat-x", + project_ref: BRANCH_REF, + parent_project_ref: PARENT_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}; + +/** `V1GetABranch` body for a branch-name `--project-ref` lookup (CLI-2289). */ +const BRANCH_BY_NAME = { + id: BRANCH_UUID, + name: "staging", + project_ref: BRANCH_REF, + parent_project_ref: TEST_PROJECT.ref, + is_default: false, + persistent: true, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}; + +/** `V1GetABranchConfig` body for a UUID `--project-ref` lookup (CLI-2289). */ +const BRANCH_CONFIG = { + ref: UUID_TARGET_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "h", + db_port: 5432, +}; + /** Routes mock HTTP responses by URL path so a single handler serves every endpoint. */ interface RouteOpts { readonly addons?: { status: number; body: unknown }; @@ -79,6 +162,16 @@ interface RouteOpts { readonly postgresPut?: { status: number; body: unknown }; readonly storageGet?: { status: number; body: unknown }; readonly storagePatch?: { status: number; body: unknown }; + // CLI-2168/CLI-2289 — live target-detection probe and branch-name/UUID + // resolution. Defaults keep every existing (plain-project) scenario + // working without opting in: a project ref probe succeeds, and the + // branch-lookup endpoints degrade to "not found"/empty rather than + // hanging or decode-erroring. `"fail"` simulates a transport failure + // (distinct from an explicit status code) for the hard-failure scenarios. + readonly project?: { status: number; body: unknown } | "fail"; + readonly branchList?: { status: number; body: unknown }; + readonly branchByName?: { status: number; body: unknown } | "fail"; + readonly branchById?: { status: number; body: unknown }; } function setup(opts: { @@ -98,6 +191,13 @@ function setup(opts: { readonly workdir?: string; /** Analytics mock for tests asserting on captured telemetry events. */ readonly analytics?: ReturnType<typeof mockAnalytics>; + /** + * `cliSettings.projectId` override — defaults to `Option.some(TEST_PROJECT.ref)`. CLI-2168/CLI-2289 + * scenarios pass `Option.none()` so ref resolution falls through to the + * `.temp/project-ref` file, or `Option.some(<ref>)` to model an env override + * distinct from any linked-state files. + */ + readonly projectId?: Option.Option<string>; }) { writeConfig(opts.toml); const routes = opts.routes ?? {}; @@ -140,6 +240,34 @@ function setup(opts: { const p = routes.storagePatch ?? { status: 200, body: {} }; return Effect.succeed(legacyJsonResponse(request, p.status, p.body)); } + const pathname = new URL(url).pathname; + // CLI-2168's live target-detection probe: a bare project ref defaults + // to a schema-valid project, so every existing (plain-project) + // scenario keeps working without opting in. + if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { + if (routes.project === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + const p = routes.project ?? { status: 200, body: TEST_PROJECT }; + return Effect.succeed(legacyJsonResponse(request, p.status, p.body)); + } + // CLI-2289's branch resolution + the best-effort branch-name lookup — + // defaults degrade to "not found"/empty rather than hanging. + if (/^\/v1\/projects\/[a-z0-9-]+\/branches$/.test(pathname)) { + const b = routes.branchList ?? { status: 200, body: [] }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + if (/^\/v1\/projects\/[a-z0-9-]+\/branches\/[^/]+$/.test(pathname)) { + if (routes.branchByName === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + const b = routes.branchByName ?? { status: 404, body: {} }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + if (/^\/v1\/branches\/[0-9a-f-]+$/.test(pathname)) { + const b = routes.branchById ?? { status: 404, body: {} }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } // Anything else (auth/storage/etc.) — succeed with empty so unconfigured // gated services don't hang if a test enables them. return Effect.succeed(legacyJsonResponse(request, 200, {})); @@ -151,7 +279,10 @@ function setup(opts: { buildLegacyTestRuntime({ out, api, - cliSettings: mockLegacyCliSettings({ workdir: opts.workdir ?? tempRoot.current }), + cliSettings: mockLegacyCliSettings({ + workdir: opts.workdir ?? tempRoot.current, + ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), + }), runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, @@ -190,6 +321,44 @@ const STORAGE_CONFIG_WITHOUT_POOL_MODE = { migrationVersion: "20240701", }; +/** + * The realistic "already ran `supabase link <branch>`" state (CLI-2168): + * `.temp/project-ref` holds the BRANCH's own ref, `.temp/linked-project.json` + * caches the PARENT (name "My App"), the live probe 404s (the ref is a + * branch, not a project), and the parent's branch list confirms it — so the + * target resolves to `{ kind: "branch", ref: BRANCH_REF, parentRef: + * PARENT_REF, parentName: "My App", branch: "feat-x" }`. `projectId: + * Option.none()` so ref resolution reads the `.temp/project-ref` file + * instead of the (env-equivalent) default. + */ +function setupLinkedBranchPush( + opts: { + readonly format?: "text" | "json" | "stream-json"; + readonly yes?: boolean; + readonly confirm?: ReadonlyArray<boolean>; + readonly stdinIsTty?: boolean; + readonly pipedAnswers?: ReadonlyArray<string>; + } = {}, +) { + writeLinkedProjectRefFile(BRANCH_REF); + writeLinkedProjectCacheFile({ ref: PARENT_REF, name: "My App" }); + return setup({ + toml: API_ONLY_TOML, + projectId: Option.none(), + format: opts.format, + yes: opts.yes, + confirm: opts.confirm, + stdinIsTty: opts.stdinIsTty, + pipedAnswers: opts.pipedAnswers, + routes: { + project: { status: 404, body: {} }, + branchList: { status: 200, body: [BRANCH_LIST_ITEM] }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); +} + describe("legacy config push integration", () => { it.live("pushes local config (text, Go parity) and surfaces a PATCH failure", () => { const { layer, out } = setup({ @@ -205,7 +374,9 @@ describe("legacy config push integration", () => { return Effect.gen(function* () { const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).toContain("Pushing config to project: abcdefghijklmnopqrst"); + expect(out.stderrText).toContain( + "Pushing config to project: Test Project (abcdefghijklmnopqrst)", + ); expect(out.stderrText).toContain("Updating API service with config:"); }).pipe(Effect.provide(layer)); }); @@ -219,6 +390,33 @@ describe("legacy config push integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "a branch name/UUID target resolves (or fails resolving) BEFORE a malformed config.toml is ever read", + () => { + // Branch/UUID resolution runs before the config load (unlike a + // ref-shaped/absent target, which never needs a network call to + // resolve at all) — a `[remotes.<name>]` overlay is merged INSIDE + // `loadCliConfig` itself, before its one full schema decode, so + // resolving the target first and loading exactly once is the only way + // to both avoid a double-decode and let a base config that's only + // valid once the matching remote applies still succeed. The accepted + // tradeoff: an unresolvable branch name costs a network round trip + // even though the local config.toml is malformed and would abort + // anyway once reached. + const { layer, api } = setup({ toml: "malformed", yes: true }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("somebranch") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushBranchNotFoundError"); + expect(rendered).not.toContain("LegacyConfigPushLoadConfigError"); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("merges a matching [remotes.*] block over the base and pushes it", () => { const { layer, out, api } = setup({ toml: `${API_ONLY_TOML}[api] @@ -575,6 +773,10 @@ function addonsHttpLayer(): Layer.Layer<HttpClient.HttpClient> { // api + db.settings run before every gated service; keep them up-to-date so // only the service under test produces a diff. const baseStubs = { + // CLI-2168's live target-detection probe runs before any gated service — + // default to the same schema-valid fixture `setup()`'s HTTP-level mock + // uses, so every existing scenario keeps working without opting in. + getProject: () => Effect.succeed(TEST_PROJECT), getPostgrestServiceConfig: () => Effect.succeed({ db_schema: "public,graphql_public", @@ -1019,6 +1221,758 @@ enabled = true }); }); +describe("legacy config push branch/project target detection (CLI-2168)", () => { + it.live("a plain project push never triggers the branch confirmation gate", () => { + const { layer, out, api } = setup({ toml: API_ONLY_TOML, yes: true }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + `Pushing config to project: Test Project (${TEST_PROJECT.ref})`, + ); + expect(out.stderrText).not.toContain("Pushing config to branch"); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "an empty project name from the live probe degrades to the bare-ref target-echo line", + () => { + // `normalizeApiName` (push.branch-target.ts, shared by both the + // project-probe and branch-list call sites) folds an empty `name` + // into `undefined` before it ever reaches the target object. + // `legacyFormatNamedRef` also has its own defensive empty-string + // check, so this specific text-echo assertion is a belt-and-suspenders + // proof of the end-to-end behavior rather than of `normalizeApiName` + // in isolation — but it's the one place `target.name` is actually + // consumed, so it's still the correct place to pin "an empty API name + // degrades to the bare-ref line, never a stray `project: (<ref>)`". + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { project: { status: 200, body: { ...TEST_PROJECT, name: "" } } }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to project: ${TEST_PROJECT.ref}\n`); + expect(out.stderrText).not.toContain("project: ("); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "the realistic 'link <branch>' then flag-less push flow shows both target lines and proceeds", + () => { + const { layer, out, api } = setupLinkedBranchPush({ yes: true }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: feat-x (${BRANCH_REF})`); + expect(out.stderrText).toContain(` Parent project: My App (${PARENT_REF})`); + expect( + api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), + ).toBe(true); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "a branch push with no branch-list match still trusts a just-linked parent (no cached name)", + () => { + writeLinkedProjectRefFile(BRANCH_REF); + writeLinkedProjectCacheFile({ ref: PARENT_REF }); + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.none(), + routes: { + project: { status: 404, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: ${BRANCH_REF}`); + expect(out.stderrText).toContain(` Parent project: ${PARENT_REF}`); + expect(out.stderrText).not.toContain("My App"); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "a live 404 with nothing cached degrades to a bare branch line with zero branch lookups", + () => { + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 404, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}\n`); + expect(out.stderrText).not.toContain("Parent project:"); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("declining the branch confirmation prompt on a TTY fails before any mutation", () => { + const { layer, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyConfigPushCancelledError"); + } + expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); + expect(api.requests.some((r) => r.url.includes("/postgrest"))).toBe(false); + expect(api.requests.some((r) => r.url.includes("/config/database/postgres"))).toBe(false); + expect(api.requests.some((r) => r.url.includes("/config/storage"))).toBe(false); + // Legacy Shell Invariant #1: a declined branch gate still flushes + // telemetry and writes the linked-project cache, same as any other + // failure. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an unattended run with no --yes and empty stdin declines and fails by default", () => { + const { layer, api } = setupLinkedBranchPush({ stdinIsTty: false }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyConfigPushCancelledError"); + } + expect(api.requests.some((r) => r.url.includes("/postgrest"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("an explicit piped 'n' on non-TTY stdin declines and fails a branch push", () => { + const { layer, api } = setupLinkedBranchPush({ + stdinIsTty: false, + pipedAnswers: ["n"], + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyConfigPushCancelledError"); + } + expect(api.requests.some((r) => r.url.includes("/postgrest"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("--yes auto-confirms a branch push and echoes the prompt", () => { + const { layer, out, api } = setupLinkedBranchPush({ yes: true }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + 'branch "feat-x" (' + BRANCH_REF + ")? (skip this check with --yes) [y/N] y", + ); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "json output mode declines and fails a branch push without --yes (CLI-2168 safety default)", + () => { + const { layer, out, api } = setupLinkedBranchPush({ format: "json" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.none() }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const rendered = JSON.stringify(exit.cause); + expect(rendered).toContain("LegacyConfigPushCancelledError"); + // A machine-mode/non-TTY decline never renders the interactive + // prompt's own "(skip this check with --yes)" hint at all + // (`legacyPromptYesNo` returns the default silently) — the + // cancelled error's own `suggestion` field is the ONLY place a + // script/agent sees the --yes escape hatch. `normalizeCliError` + // reads `suggestion` generically off any tagged error, and + // `output.fail` threads it into the machine error envelope. + expect(rendered).toContain("--yes"); + expect(rendered).toContain("SUPABASE_YES"); + } + expect(out.messages.some((m) => m.type === "success")).toBe(false); + expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("json output mode with --yes reports the branch target in the machine payload", () => { + const { layer, out } = setupLinkedBranchPush({ format: "json", yes: true }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.project_ref).toBe(BRANCH_REF); + expect(success?.data?.is_branch).toBe(true); + expect(success?.data?.branch).toBe("feat-x"); + expect(success?.data?.parent_project_ref).toBe(PARENT_REF); + expect(Array.isArray(success?.data?.services)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "an unrelated cached parent does not get credited without a confirming branch-list match", + () => { + writeLinkedProjectCacheFile({ ref: PARENT_REF }); + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 404, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); + expect(out.stderrText).not.toContain("Parent project:"); + expect( + api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a self-referential cached parent is dropped without any branch-list lookup", () => { + writeLinkedProjectCacheFile({ ref: PROBE_REF }); + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 404, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); + expect(out.stderrText).not.toContain("Parent project:"); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("a non-ref-shaped cached parent is dropped without any branch-list lookup", () => { + writeLinkedProjectCacheFile({ ref: "not-a-real-ref" }); + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 404, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); + expect(out.stderrText).not.toContain("Parent project:"); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "a transport failure probing the live target degrades to unknown rather than aborting the push", + () => { + // The target-detection probe is diagnostic-only (CLI-2168 review + // finding): a transport failure must never abort a push that would + // otherwise succeed — including for a plain project whose token can + // write service config but happens to fail this one informational + // read. It degrades to "unknown" (never "branch" — that would + // wrongly gate an ordinary push behind a confirmation that + // auto-declines, and fails, in an unattended run) and the push + // proceeds. + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: "fail", + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + `Pushing config to: ${PROBE_REF} (could not determine whether this is a branch or the main project)`, + ); + expect(out.stderrText).not.toContain("Do you want to push config to branch"); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "a broken .temp/project-ref (a directory, not a file) degrades gracefully instead of failing", + () => { + // Mirrors the established `legacyReadProjectRefFile` EISDIR regression + // technique (`legacy-temp-paths.unit.test.ts`) — the target-detection + // recovery's own best-effort read must swallow a real read failure + // (not just a missing file), not propagate it. A cache candidate (with + // a branch-list response that does NOT confirm this ref) is required + // so recovery actually reaches the `.temp/project-ref` read at all — + // with no cache candidate at all, it returns before ever attempting + // that read. + mkdirSync(join(tempRoot.current, "supabase", ".temp", "project-ref"), { recursive: true }); + writeLinkedProjectCacheFile({ ref: PARENT_REF }); + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 404, body: {} }, + branchList: { status: 200, body: [] }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect( + api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), + ).toBe(true); + // The EISDIR read degrades to "no file ref", so the fallback trust + // check (`fileRef.value === ref`) can't fire either — same bare + // shape a genuinely absent file would produce. + expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); + expect(out.stderrText).not.toContain("Parent project:"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a 500 probing the live target degrades to unknown rather than aborting the push", () => { + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 500, body: { message: "boom" } }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + `Pushing config to: ${PROBE_REF} (could not determine whether this is a branch or the main project)`, + ); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "an unknown target (a live probe failure) never carries is_branch in the machine payload", + () => { + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: true, + format: "json", + projectId: Option.some(PROBE_REF), + routes: { + project: { status: 500, body: {} }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.project_ref).toBe(PROBE_REF); + expect("is_branch" in (success?.data ?? {})).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + // The "transport failure"/"500" tests above cover the `"unknown"` outcome + // for a genuine probe error; only the TIMEOUT-specific sub-case (a live + // probe that hangs rather than erroring) remains untested — both reach + // the identical `{ kind: "unknown" }` outcome, so this is a coverage gap + // in HOW `"unknown"` is reached, not in the behavior itself. A + // `TestClock`-driven proof of the `LEGACY_BRANCH_LOOKUP_TIMEOUT` + // degradation was deliberately NOT added here — see the test-report notes + // for why: `legacyConfigPush` does + // substantial real, unmocked filesystem I/O (project-root discovery, + // config.toml read, `.env` load) via `BunFileSystem` before it ever + // reaches the probe's `Effect.timeoutOrElse`, and that I/O settles on a + // real event-loop macrotask turn that a virtual `TestClock` cannot + // provide. `it.effect` + `Effect.forkChild` + `TestClock.adjust` + + // `Fiber.join` (this codebase's own precedent, e.g. + // `legacy-branch-target` health-check tests) hangs the full 5s Vitest + // timeout here, even preceded by up to 20 `Effect.yieldNow` turns — + // confirming the block is real macrotask I/O, not merely an + // under-scheduled fiber. Forcing it through would need either a real + // wall-clock wait (banned by this repo's flake-resistance policy) or + // swapping this one test's `FileSystem` for an in-memory fake diverging + // from every other scenario in this file — neither is a "cheap addition". +}); + +describe("legacy config push --project-ref branch name/UUID resolution (CLI-2289)", () => { + it.live( + "--project-ref <branch-name> resolves via the already-known parent, no extra live probe", + () => { + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { + branchByName: { status: 200, body: BRANCH_BY_NAME }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.some("staging") }); + expect(out.stderrText).toContain(`Pushing config to branch: staging (${BRANCH_REF})`); + expect(out.stderrText).toContain(` Parent project: ${TEST_PROJECT.ref}`); + expect( + api.requests.some( + (r) => r.method === "GET" && new URL(r.url).pathname === `/v1/projects/${BRANCH_REF}`, + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "--project-ref <branch-name> skips the branch confirmation prompt, with no --yes and no queued answer", + () => { + // `knownBranch` is `{kind: "name", branchName, parentRef}` for a NAME + // target, so push.handler.ts's `knownBranch === undefined` gate is + // never entered — no prompt at all. `yes: false` + // with no `confirm`/`pipedAnswers` queued at all means the mock's own + // "no more queued answers" fallback (`promptConfirmResponses.shift() + // ?? true`) would silently mask a wrongly-shown prompt if we only + // asserted success, so the real proof is `out.stderrText` never + // containing the branch-prompt label — `legacyPromptYesNo` always + // writes its label to stderr before reading any answer, on both a TTY + // and non-TTY, so its total absence is conclusive either way. + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: false, + routes: { + branchByName: { status: 200, body: BRANCH_BY_NAME }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain(`Pushing config to branch: staging (${BRANCH_REF})`); + expect(out.stderrText).not.toContain("Do you want to push config to branch"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "--project-ref <branch-name> enriches the parent name from a matching linked-project cache", + () => { + writeLinkedProjectCacheFile({ ref: TEST_PROJECT.ref, name: "Test Project" }); + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { + branchByName: { status: 200, body: BRANCH_BY_NAME }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.some("staging") }); + expect(out.stderrText).toContain(` Parent project: Test Project (${TEST_PROJECT.ref})`); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "--project-ref <branch-name> ignores a linked-project cache belonging to a different parent", + () => { + writeLinkedProjectCacheFile({ ref: OTHER_PARENT_REF, name: "Someone Else" }); + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { + branchByName: { status: 200, body: BRANCH_BY_NAME }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.some("staging") }); + expect(out.stderrText).toContain(` Parent project: ${TEST_PROJECT.ref}`); + expect(out.stderrText).not.toContain("Someone Else"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "--project-ref <branch-name> resolution works without a spinner in json output mode", + () => { + const { layer, out } = setup({ + toml: API_ONLY_TOML, + yes: true, + format: "json", + routes: { + branchByName: { status: 200, body: BRANCH_BY_NAME }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.some("staging") }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.is_branch).toBe(true); + expect(success?.data?.branch).toBe("staging"); + expect(success?.data?.parent_project_ref).toBe(TEST_PROJECT.ref); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("--project-ref <uuid> resolves without any linked project (CLI-2289 regression)", () => { + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.none(), + routes: { + project: { status: 404, body: {} }, + branchById: { status: 200, body: BRANCH_CONFIG }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some(BRANCH_UUID) }).pipe( + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(out.stderrText).not.toContain(BRANCH_UUID); + const branchRequests = api.requests.filter((r) => r.url.includes("/branches")); + expect(branchRequests).toHaveLength(1); + expect(branchRequests[0]?.url).toContain(`/v1/branches/${BRANCH_UUID}`); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "--project-ref <uuid> never shows the branch confirmation prompt, on an unattended run with no --yes", + () => { + // `knownBranch` is `{kind: "uuid"}` (defined, not `undefined`) for a + // UUID target — the same "explicit target this invocation" shape a + // branch NAME target gets, so push.handler.ts's `target.kind === + // "branch" && knownBranch === undefined` gate is never entered: no + // prompt at all, even on a + // fully unattended run (no `--yes`, non-TTY, empty stdin). Checking + // `out.stderrText` for the exact branch-prompt label (rather than + // relying on `--yes`'s own echo, which the existing UUID regression + // test above already uses and which cannot distinguish "prompted then + // auto-confirmed" from "never prompted") is the only reliable signal + // here: `legacyPromptYesNo` prints its label to stderr even on a + // non-TTY before scanning stdin, so its total absence proves the gate + // never ran. Per-service prompts (`keep()`) still default to `true` on + // empty non-TTY stdin (mirrors the "defaults to yes on empty non-TTY + // stdin" test above), so the mutation still proceeds. + const { layer, out, api } = setup({ + toml: API_ONLY_TOML, + yes: false, + stdinIsTty: false, + projectId: Option.none(), + routes: { + project: { status: 404, body: {} }, + branchById: { status: 200, body: BRANCH_CONFIG }, + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some(BRANCH_UUID) }).pipe( + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(out.stderrText).not.toContain("Do you want to push config to branch"); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("--project-ref <unknown-branch-name> fails with a branches-list suggestion", () => { + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { branchByName: { status: 404, body: { message: "not found" } } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("ghost") }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushBranchNotFoundError"); + expect(rendered).toContain('Branch \\"ghost\\" not found'); + expect(rendered).toContain("supabase branches list"); + expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); + // Legacy Shell Invariant #1: telemetry flushes even though ref + // resolution itself failed — but no ref was ever resolved, so the + // linked-project cache stays untouched (mirrors `diff.integration.test.ts`'s + // equivalent "an unknown branch fails..." assertion). + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "a branch-name lookup failure in json mode still resolves cleanly without a spinner", + () => { + const { layer, api } = setup({ + toml: API_ONLY_TOML, + yes: true, + format: "json", + routes: { branchByName: { status: 404, body: { message: "not found" } } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("ghost") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPushBranchNotFoundError"); + expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("--project-ref <branch-name> in an unlinked directory fails naming the value", () => { + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.none(), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("somebranch") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushBranchNotLinkedError"); + expect(rendered).toContain('\\"somebranch\\"'); + expect(api.requests).toHaveLength(0); + // Legacy Shell Invariant #1: fails purely from local file/env state, + // before any ref is resolved — telemetry still flushes, but the + // linked-project cache write is a no-op (mirrors `diff.integration.test.ts`'s + // equivalent "in an unlinked dir fails immediately" assertion). + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--project-ref <branch-name> with a corrupt linked ref reports it as invalid", () => { + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + projectId: Option.some("not-a-valid-ref"), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("somebranch") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushParentRefInvalidError"); + expect(rendered).toContain('\\"somebranch\\"'); + expect(api.requests).toHaveLength(0); + // Legacy Shell Invariant #1: no ref ever resolved here either. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a resolved branch with no project ref yet fails with a not-ready error", () => { + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigPushBranchNotReadyError"); + expect(rendered).toContain("has no project ref yet"); + expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); + // This fails inside `legacyResolveConfigTarget` itself (the + // placeholder ref is rejected before it's ever assigned to the + // handler's `resolvedRef`), so the cache write is still a no-op. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a transport failure resolving a branch name maps to the resolve network error", () => { + const { layer, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { branchByName: "fail" }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPushBranchResolveNetworkError"); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a non-404 branch-name lookup failure keeps its status error", () => { + const { layer, telemetry, linkedProjectCache } = setup({ + toml: API_ONLY_TOML, + yes: true, + routes: { branchByName: { status: 500, body: { message: "boom" } } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigPush({ projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigPushBranchResolveStatusError"); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); +}); + describe("legacy config push telemetry wiring", () => { // Drives the exact `Command.withHandler` wiring (legacyConfigPushHandler) // rather than the bare handler: the safeFlags guard lives in the wiring, diff --git a/apps/cli/src/legacy/commands/config/push/push.unit.test.ts b/apps/cli/src/legacy/commands/config/push/push.unit.test.ts new file mode 100644 index 0000000000..e636d9055d --- /dev/null +++ b/apps/cli/src/legacy/commands/config/push/push.unit.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "vitest"; + +import type { LegacyConfigPushTarget } from "./push.branch-target.ts"; +import { + legacyConfigPushBranchPromptLabel, + legacyConfigPushPayloadFields, + legacyConfigPushTargetLines, +} from "./push.format.ts"; + +const REF = "abcdefghijklmnopqrst"; +const PARENT_REF = "pppppppppppppppppppp"; + +describe("legacyConfigPushTargetLines", () => { + test("a plain project with no known name stays byte-identical to the pre-CLI-2168 line", () => { + expect(legacyConfigPushTargetLines({ kind: "project", ref: REF })).toBe( + `Pushing config to project: ${REF}\n`, + ); + }); + + test("a plain project with a known name shows the name alongside the ref", () => { + expect(legacyConfigPushTargetLines({ kind: "project", ref: REF, name: "Test Project" })).toBe( + `Pushing config to project: Test Project (${REF})\n`, + ); + }); + + test("a named branch with a named parent shows both lines", () => { + const target: LegacyConfigPushTarget = { + kind: "branch", + ref: REF, + branch: "feat-x", + parentRef: PARENT_REF, + parentName: "My App", + }; + expect(legacyConfigPushTargetLines(target)).toBe( + `Pushing config to branch: feat-x (${REF})\n Parent project: My App (${PARENT_REF})\n`, + ); + }); + + test("a named branch with a known but unnamed parent omits the parent's name", () => { + const target: LegacyConfigPushTarget = { + kind: "branch", + ref: REF, + branch: "feat-x", + parentRef: PARENT_REF, + }; + expect(legacyConfigPushTargetLines(target)).toBe( + `Pushing config to branch: feat-x (${REF})\n Parent project: ${PARENT_REF}\n`, + ); + }); + + test("a named branch with no known parent at all omits the parent line entirely", () => { + const target: LegacyConfigPushTarget = { kind: "branch", ref: REF, branch: "feat-x" }; + expect(legacyConfigPushTargetLines(target)).toBe(`Pushing config to branch: feat-x (${REF})\n`); + }); + + test("an unnamed branch with a named parent shows a bare branch ref and the parent's name", () => { + const target: LegacyConfigPushTarget = { + kind: "branch", + ref: REF, + parentRef: PARENT_REF, + parentName: "My App", + }; + expect(legacyConfigPushTargetLines(target)).toBe( + `Pushing config to branch: ${REF}\n Parent project: My App (${PARENT_REF})\n`, + ); + }); + + test("a bare branch with nothing else known renders a single line", () => { + expect(legacyConfigPushTargetLines({ kind: "branch", ref: REF })).toBe( + `Pushing config to branch: ${REF}\n`, + ); + }); + + test("control characters and ANSI escapes in a project name are sanitized", () => { + // Routes through `legacyFormatNamedRef`/`legacySanitizeInlineName` (not + // re-tested here) — this asserts the formatter actually calls it, not + // that the sanitizer itself works. + const rendered = legacyConfigPushTargetLines({ + kind: "project", + ref: REF, + name: "evil\x1b[31mred", + }); + expect(rendered).toBe(`Pushing config to project: evil[31mred (${REF})\n`); + expect(rendered).not.toContain("\x1b"); + }); + + test("a newline in a branch/parent name is collapsed instead of forging extra output lines", () => { + const target: LegacyConfigPushTarget = { + kind: "branch", + ref: REF, + branch: "evil\nbranch", + parentRef: PARENT_REF, + parentName: "evil\nparent", + }; + expect(legacyConfigPushTargetLines(target)).toBe( + `Pushing config to branch: evil branch (${REF})\n Parent project: evil parent (${PARENT_REF})\n`, + ); + }); +}); + +describe("legacyConfigPushBranchPromptLabel", () => { + const skipHint = " (skip this check with --yes)"; + + test("a branch with no known name prompts against the bare ref", () => { + expect(legacyConfigPushBranchPromptLabel({ kind: "branch", ref: REF })).toBe( + `Do you want to push config to branch ${REF}?${skipHint}`, + ); + }); + + test("a branch with a known name quotes it alongside the ref", () => { + expect(legacyConfigPushBranchPromptLabel({ kind: "branch", ref: REF, branch: "feat-x" })).toBe( + `Do you want to push config to branch "feat-x" (${REF})?${skipHint}`, + ); + }); + + test("a hostile branch name cannot inject control characters into the prompt label", () => { + expect( + legacyConfigPushBranchPromptLabel({ + kind: "branch", + ref: REF, + branch: "evil\x1b[31m\nname", + }), + ).toBe(`Do you want to push config to branch "evil[31m name" (${REF})?${skipHint}`); + }); +}); + +describe("legacyConfigPushPayloadFields", () => { + test("a plain project carries only is_branch: false", () => { + expect(legacyConfigPushPayloadFields({ kind: "project", ref: REF })).toEqual({ + is_branch: false, + }); + }); + + test("a named branch with a known parent carries both branch and parent_project_ref", () => { + expect( + legacyConfigPushPayloadFields({ + kind: "branch", + ref: REF, + branch: "feat-x", + parentRef: PARENT_REF, + }), + ).toEqual({ is_branch: true, branch: "feat-x", parent_project_ref: PARENT_REF }); + }); + + test("a named branch with no known parent omits parent_project_ref", () => { + expect(legacyConfigPushPayloadFields({ kind: "branch", ref: REF, branch: "feat-x" })).toEqual({ + is_branch: true, + branch: "feat-x", + }); + }); + + test("an unnamed branch with a known parent omits branch", () => { + expect( + legacyConfigPushPayloadFields({ kind: "branch", ref: REF, parentRef: PARENT_REF }), + ).toEqual({ is_branch: true, parent_project_ref: PARENT_REF }); + }); + + test("a bare branch with nothing known carries only is_branch: true", () => { + expect(legacyConfigPushPayloadFields({ kind: "branch", ref: REF })).toEqual({ + is_branch: true, + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index bb9c19d841..e30f45f092 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -1,7 +1,6 @@ -import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; +import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { Duration, Effect, FileSystem, Option, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../../config/legacy-cli-settings.service.ts"; @@ -21,6 +20,7 @@ import { PropLinkedVia, PropParentProjectRef, } from "../../../shared/telemetry/event-catalog.ts"; +import { legacyClassifyProjectLookupError } from "../../shared/legacy-branch-target.ts"; import { type LegacyCachedLinkedProject, legacyParentNotLinkedMessage, @@ -31,11 +31,7 @@ import { } from "../../shared/legacy-parent-project-ref.ts"; import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; import { legacyMapTenantApiKeysError } from "../../shared/legacy-get-tenant-api-keys.ts"; -import { - legacySanitizeInlineName, - mapLegacyHttpError, - sanitizeLegacyErrorBody, -} from "../../shared/legacy-http-errors.ts"; +import { legacySanitizeInlineName, mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; import { legacyLinkServicesCore } from "../../shared/legacy-link-services-core.ts"; import { legacyExtractServiceKeys } from "../../shared/legacy-tenant-keys.ts"; import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; @@ -56,7 +52,6 @@ import { } from "./link.errors.ts"; import type { LegacyLinkFlags } from "./link.command.ts"; -type LegacyLinkProject = Effect.Success<ReturnType<ApiClient["v1"]["getProject"]>>; type LegacyLinkBranches = typeof V1ListAllBranchesOutput.Type; type LegacyLinkBranch = LegacyLinkBranches[number]; @@ -70,45 +65,13 @@ interface LegacyLinkBranchResolution { // Classify a `getProject` failure: a 404 means the project is a branch (resolve // to `None`, link continues); any other status surfaces the body; transport -// failures surface a network error. Mirrors `checkRemoteProjectStatus` -// (`link.go:240-253`). -const classifyProjectError = ( - cause: unknown, -): Effect.Effect< - Option.Option<LegacyLinkProject>, - LegacyLinkProjectStatusError | LegacyLinkProjectStatusNetworkError -> => { - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - const status = cause.response.status; - if (status === 404) { - return Effect.succeedNone; - } - return cause.response.text.pipe( - Effect.orElseSucceed(() => ""), - // Cap + strip control chars, matching `mapLegacyHttpError`'s defence-in-depth - // so an oversized / control-char body can't bloat JSON output or inject ANSI. - Effect.map(sanitizeLegacyErrorBody), - Effect.flatMap((body) => - Effect.fail( - new LegacyLinkProjectStatusError({ - status, - body, - message: `Unexpected error retrieving remote project status: ${body}`, - }), - ), - ), - ); - } - // Everything else: a transport `HttpClientError` (no response) is a network - // failure; a non-`HttpClientError` (the generated client's `SchemaError` - // rejecting the response body) is an API response problem. - return Effect.fail( - new LegacyLinkProjectStatusNetworkError({ - message: `failed to retrieve remote project status: ${String(cause)}`, - decode: !HttpClientError.isHttpClientError(cause), - }), - ); -}; +// failures surface a network error. +const classifyProjectError = legacyClassifyProjectLookupError({ + statusError: LegacyLinkProjectStatusError, + networkError: LegacyLinkProjectStatusNetworkError, + statusMessage: (_status, body) => `Unexpected error retrieving remote project status: ${body}`, + networkMessage: (cause) => `failed to retrieve remote project status: ${String(cause)}`, +}); type WriteTempFile = (filePath: string, content: string) => Effect.Effect<void, PlatformError>; @@ -117,8 +80,8 @@ const mapApiKeysError = legacyMapTenantApiKeysError({ statusError: LegacyLinkAuthTokenError, }); -// Same reasoning + duration as `legacy-linked-state.ts`'s status-lookup bound -// (`LEGACY_LINKED_STATE_LOOKUP_TIMEOUT`) — duplicated locally rather than +// Same reasoning + duration as `legacy-branch-target.ts`'s branch-lookup bound +// (`LEGACY_BRANCH_LOOKUP_TIMEOUT`) — duplicated locally rather than // shared across two otherwise-unrelated modules: the best-effort 404-path // stale-cache correlation lookup below must not let an otherwise-successful // `link` silently stall ~6 minutes at the very end on the generated client's diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index e46e403941..0cf4bc2c1d 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -327,7 +327,7 @@ the Go CLI's own contract exactly. The additive failure envelope above is scoped resolvable parent, no Management API service in scope, an offline/token-less `LegacyPlatformApiFactory` acquisition failure, or a failed/empty branch lookup all degrade rather than erroring, so this feature can never be the reason `status` fails or its exit code - changes. `legacyAcquireLinkedStateApi` (in `legacy-linked-state.ts`) tries + changes. `legacyAcquireBranchLookupApi` (in `legacy-branch-target.ts`) tries `Effect.serviceOption(LegacyPlatformApi)` first (the cheapest path, and what tests provide directly), then falls back to `Effect.serviceOption(LegacyPlatformApiFactory)` → `factory.make` with every failure caught — `status`'s runtime layer only ever wires up the lazy factory (see diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 6bb5dabab2..cdc02c2aa3 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -126,7 +126,7 @@ function legacyTransportFailureForMock() { /** * Wires `LegacyPlatformApiFactory` directly (`make` resolves immediately to a * stubbed client) — NOT `LegacyPlatformApi`. Pins the actual production - * acquisition path `legacyAcquireLinkedStateApi` falls back to when only the + * acquisition path `legacyAcquireBranchLookupApi` falls back to when only the * lazy factory is in scope (`status`'s real runtime shape, via * `legacyPlatformApiFactoryLayer` in `status.command.ts`), as opposed to the * direct-service `branches` mock above, which existing tests provide and which @@ -314,7 +314,7 @@ interface SetupOpts { /** * When set INSTEAD of `branches`, wires only `LegacyPlatformApiFactory` * (never `LegacyPlatformApi` directly) — the shape `status`'s real runtime - * actually provides. Pins `legacyAcquireLinkedStateApi`'s factory-fallback + * actually provides. Pins `legacyAcquireBranchLookupApi`'s factory-fallback * path (CLI-2167 follow-up bug fix). */ readonly apiFactory?: { @@ -1302,7 +1302,7 @@ content_path = "./supabase/templates/password_changed_notification.html" // runtime never wires `LegacyPlatformApi` directly (only the lazy // `LegacyPlatformApiFactory`, via `legacyPlatformApiFactoryLayer` in // `status.command.ts`) — a test using the `branches` mock above - // would pass even if `legacyAcquireLinkedStateApi`'s factory fallback + // would pass even if `legacyAcquireBranchLookupApi`'s factory fallback // were broken or missing entirely. const { layer, out, workdir, apiFactoryMock } = setup({ apiFactory: { ok: [LINKED_BRANCH] }, @@ -1756,7 +1756,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live( - "a branch lookup that never resolves times out and degrades to the RICH block (real 5s wait — LEGACY_LINKED_STATE_LOOKUP_TIMEOUT is a module-private constant in legacy-linked-state.ts, not monkey-patchable; accepted as a real-time test for this one scenario, PR #6168 review)", + "a branch lookup that never resolves times out and degrades to the RICH block (real 5s wait — LEGACY_BRANCH_LOOKUP_TIMEOUT is an exported constant in legacy-branch-target.ts, but its VALUE isn't overridable without changing the source; accepted as a real-time test for this one scenario, PR #6168 review)", () => { const { layer, out, workdir } = setup(); writeProjectRefFile(workdir, LINKED_BRANCH_REF); diff --git a/apps/cli/src/legacy/shared/legacy-branch-target.ts b/apps/cli/src/legacy/shared/legacy-branch-target.ts new file mode 100644 index 0000000000..bf0892b02c --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-branch-target.ts @@ -0,0 +1,159 @@ +import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; +import { Duration, Effect, Option } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; + +import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { + sanitizeLegacyErrorBody, + type NetworkErrorFactory, + type StatusErrorFactory, +} from "./legacy-http-errors.ts"; + +type LegacyBranchLookupBranches = typeof V1ListAllBranchesOutput.Type; + +/** + * Classifies a `GET /v1/projects/{ref}` failure for a ref that might actually + * be a branch: a 404 means `ref` is a branch — resolve to `None` so the + * caller proceeds treating it as one; any other status surfaces the response + * body through the caller-supplied status-error factory/message; a transport + * failure, or a non-`HttpClientError` cause (the generated client's + * `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 + * 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()`: + * + * ```ts + * api.v1.getProject({ ref }).pipe(Effect.asSome, Effect.catch(legacyClassifyProjectLookupError(opts))) + * ``` + */ +export function legacyClassifyProjectLookupError<S, N>(opts: { + readonly statusError: StatusErrorFactory<S>; + readonly networkError: NetworkErrorFactory<N>; + readonly statusMessage: (status: number, body: string) => string; + readonly networkMessage: (cause: unknown) => string; +}): (cause: unknown) => Effect.Effect<Option.Option<never>, S | N> { + return (cause) => { + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const status = cause.response.status; + if (status === 404) { + return Effect.succeedNone; + } + return cause.response.text.pipe( + Effect.orElseSucceed(() => ""), + // Cap + strip control chars, matching `mapLegacyHttpError`'s defence-in-depth + // so an oversized / control-char body can't bloat JSON output or inject ANSI. + Effect.map(sanitizeLegacyErrorBody), + Effect.flatMap((body) => + Effect.fail( + new opts.statusError({ + status, + body, + message: opts.statusMessage(status, body), + }), + ), + ), + ); + } + // Everything else: a transport `HttpClientError` (no response) is a network + // failure; a non-`HttpClientError` (the generated client's `SchemaError` + // rejecting the response body) is an API response problem. + return Effect.fail( + new opts.networkError({ + message: opts.networkMessage(cause), + decode: !HttpClientError.isHttpClientError(cause), + }), + ); + }; +} + +/** + * Acquires a Management API client for a best-effort branch-name lookup, + * total (never fails, resolves `None` on any acquisition failure): + * + * 1. `Effect.serviceOption(LegacyPlatformApi)` — 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(LegacyPlatformApiFactory)` → `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 `LegacyPlatformApi`/`LegacyPlatformApiFactory` + * requirement at all, thanks to `Effect.serviceOption`. + */ +const legacyAcquireBranchLookupApi = Effect.fnUntraced(function* () { + const direct = yield* Effect.serviceOption(LegacyPlatformApi); + if (Option.isSome(direct)) return direct; + + const factoryOption = yield* Effect.serviceOption(LegacyPlatformApiFactory); + if (Option.isNone(factoryOption)) return Option.none<ApiClient>(); + + return yield* factoryOption.value.make.pipe( + Effect.map(Option.some), + Effect.catch(() => Effect.succeed(Option.none<ApiClient>())), + ); +}); + +// 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. +export const LEGACY_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. + * + * The WHOLE acquisition-and-listing attempt is hard-bounded by + * {@link LEGACY_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. + */ +export const legacyFindBranchName = Effect.fnUntraced(function* ( + parentRef: string, + linkedRef: string, + options: { readonly spinnerLabel?: string } = {}, +) { + const output = yield* Output; + + const branchesOption: Option.Option<LegacyBranchLookupBranches> = yield* Effect.gen(function* () { + const apiOption = yield* legacyAcquireBranchLookupApi(); + if (Option.isNone(apiOption)) return Option.none<LegacyBranchLookupBranches>(); + const api = apiOption.value; + + const task = + output.format === "text" && options.spinnerLabel !== undefined + ? yield* output.task(options.spinnerLabel) + : undefined; + return yield* api.v1 + .listAllBranches({ ref: parentRef }) + .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); + }).pipe( + Effect.timeout(LEGACY_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. + Effect.catch(() => Effect.succeed(Option.none<LegacyBranchLookupBranches>())), + ); + + return Option.isSome(branchesOption) + ? branchesOption.value.find((branch) => branch.project_ref === linkedRef)?.name + : undefined; +}); diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index b6907b915d..dba07ca5fb 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -40,6 +40,20 @@ export function legacySanitizeInlineName(input: string): string { return sanitizeLegacyErrorBody(input).replace(/[\n\t]+/g, " "); } +/** + * Renders `name (ref)` when `name` is known and non-empty after + * sanitization, or bare `ref` otherwise — both sanitized via + * {@link legacySanitizeInlineName}. Computing the sanitized name FIRST (and + * gating on ITS length, not the raw input's) is defense-in-depth: a name + * that's entirely control characters must degrade to the bare-ref form, not + * render as `` (ref)`` with a phantom leading space. + */ +export function legacyFormatNamedRef(name: string | undefined, ref: string): string { + const safeRef = legacySanitizeInlineName(ref); + const safeName = name === undefined ? undefined : legacySanitizeInlineName(name); + return safeName === undefined || safeName.length === 0 ? safeRef : `${safeName} (${safeRef})`; +} + // Strip ASCII control characters from the response body before embedding it in an error // message. The Management API is trusted, but defence-in-depth: a body containing `\r\n` // could fracture a structured log line, and `\x00` could truncate output in shells that diff --git a/apps/cli/src/legacy/shared/legacy-linked-state.ts b/apps/cli/src/legacy/shared/legacy-linked-state.ts index b52ecb9e96..b632ca85ad 100644 --- a/apps/cli/src/legacy/shared/legacy-linked-state.ts +++ b/apps/cli/src/legacy/shared/legacy-linked-state.ts @@ -1,20 +1,15 @@ -import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { Duration, Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; -import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { PROJECT_REF_PATTERN } from "../config/legacy-project-ref.service.ts"; -import { Output } from "../../shared/output/output.service.ts"; +import { legacyFindBranchName } from "./legacy-branch-target.ts"; import { type LegacyCachedLinkedProject, legacyParseCachedLinkedProject, } from "./legacy-parent-project-ref.ts"; -import { legacySanitizeInlineName } from "./legacy-http-errors.ts"; +import { legacyFormatNamedRef, legacySanitizeInlineName } from "./legacy-http-errors.ts"; import { legacyReadProjectRefFile, legacyTempPaths } from "./legacy-temp-paths.ts"; -type LegacyLinkedStateBranches = typeof V1ListAllBranchesOutput.Type; - /** * Discriminated linked-state result. * @@ -83,88 +78,6 @@ const legacyResolveSoftLinkedRef = Effect.fnUntraced(function* () { }; }); -/** - * Acquires a Management API client for the best-effort branch-name lookup, - * total (never fails, resolves `None` on any acquisition failure): - * - * 1. `Effect.serviceOption(LegacyPlatformApi)` — 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(LegacyPlatformApiFactory)` → `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 `LegacyPlatformApi`/`LegacyPlatformApiFactory` - * requirement at all, thanks to `Effect.serviceOption`. - */ -const legacyAcquireLinkedStateApi = Effect.fnUntraced(function* () { - const direct = yield* Effect.serviceOption(LegacyPlatformApi); - if (Option.isSome(direct)) return direct; - - const factoryOption = yield* Effect.serviceOption(LegacyPlatformApiFactory); - if (Option.isNone(factoryOption)) return Option.none<ApiClient>(); - - return yield* factoryOption.value.make.pipe( - Effect.map(Option.some), - Effect.catch(() => Effect.succeed(Option.none<ApiClient>())), - ); -}); - -// `status` is an interactive command; this lookup is pure decoration 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 every `status` run for -// ~6 minutes (PR #6168 review). -const LEGACY_LINKED_STATE_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 — - * this is the degradation point {@link legacyResolveLinkedState}'s one call - * site relies on). Shows the `Checking linked branch...` spinner in text - * mode, but only once an API client is actually available — an acquisition - * failure degrades silently, before ever touching the spinner. - * - * The WHOLE acquisition-and-listing attempt is hard-bounded by - * {@link LEGACY_LINKED_STATE_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. - */ -const legacyFindLinkedBranchName = Effect.fnUntraced(function* ( - parentRef: string, - linkedRef: string, -) { - const output = yield* Output; - - const branchesOption: Option.Option<LegacyLinkedStateBranches> = yield* Effect.gen(function* () { - const apiOption = yield* legacyAcquireLinkedStateApi(); - if (Option.isNone(apiOption)) return Option.none<LegacyLinkedStateBranches>(); - const api = apiOption.value; - - const task = - output.format === "text" ? yield* output.task("Checking linked branch...") : undefined; - return yield* api.v1 - .listAllBranches({ ref: parentRef }) - .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); - }).pipe( - Effect.timeout(LEGACY_LINKED_STATE_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. - Effect.catch(() => Effect.succeed(Option.none<LegacyLinkedStateBranches>())), - ); - - return Option.isSome(branchesOption) - ? branchesOption.value.find((branch) => branch.project_ref === linkedRef)?.name - : undefined; -}); - /** * Resolves the current linked-state display (project or branch). Used by * `status` to show the linked project/branch without requiring a link @@ -179,7 +92,7 @@ const legacyFindLinkedBranchName = Effect.fnUntraced(function* ( * linked ref came from the `project-ref` FILE → a branch link. Always * renders the branch-linked shape (parent ref + whatever name/org the * cache knows), attempting the best-effort branch-name lookup - * ({@link legacyFindLinkedBranchName}) and degrading to the bare + * ({@link legacyFindBranchName}) and degrading to the bare * "assumed branch, name unknown" shape — NOT to a plain/bare project * line — on any acquisition or API failure. This is the fix for the real * bug this feature shipped to fix: the user must still see they're on a @@ -242,7 +155,9 @@ export const legacyResolveLinkedState = Effect.fnUntraced(function* () { // The cache names a genuinely DIFFERENT parent than the linked ref. const parentRef = cached.value.ref; - const branch = yield* legacyFindLinkedBranchName(parentRef, linkedRef.value); + const branch = yield* legacyFindBranchName(parentRef, linkedRef.value, { + spinnerLabel: "Checking linked branch...", + }); if (branch === undefined && soft.source === "env") { // An env override's lookup didn't POSITIVELY confirm a branch, and the // cache carries none of the file-sourced trust invariants (it belongs @@ -297,11 +212,6 @@ function legacyFormatOrgLabel(slug: string | undefined, id: string | undefined): return legacySanitizeInlineName(slug ?? id ?? ""); } -function legacyFormatNamedRef(name: string | undefined, ref: string): string { - const safeRef = legacySanitizeInlineName(ref); - return name === undefined ? safeRef : `${legacySanitizeInlineName(name)} (${safeRef})`; -} - /** * Pure formatter for `LegacyLinkedState` — the full multi-line block, * including its trailing newline. Not linked stays a single plain line (no