diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 797be14ebd..c6973d2b9d 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -297,7 +297,7 @@ Verify each applicable item when adding or reworking a command: 5. **`Layer.provide` does not share to siblings inside `Layer.mergeAll`** — if two sibling layers each require `LegacyCliSettings`, provide it to both explicitly. Smoke-test the bundled binary (`bun run build && ./dist/supabase-legacy …`) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commit `a816b12e`, `backups.layers.ts:32-46`. -6. **Both `--output` (legacy machine formats) and `--output-format` must be honored** — `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on the `--output` flag first, then fall through to `--output-format` text/json/stream-json. +6. **Both `--output` (legacy machine formats) and `--output-format` must be honored** — `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on the `--output` flag first, then fall through to `--output-format` text/json/stream-json. Exception: a net-new TS-only command with no Go-compat contract may instead reject `-o`/`--output` outright (every value, including `pretty`) with an error pointing at `--output-format` — decided on CLI-2156, with `config diff` as the precedent. 7. **Telemetry follows the established catalog and payload shapes** — see the next section. @@ -310,7 +310,7 @@ The legacy shell sends PostHog events to the product analytics pipeline. Drift i - **The canonical catalog is `shared/telemetry/event-catalog.ts`.** Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. The TS catalog is the source of truth for event names and property keys. - **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits the established property shape: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`. - **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""`. -- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data. +- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys, config push/diff), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data. When a `--project-ref` also accepts branch names (link, config diff — CLI-2167 vocabulary), gate the whitelist on `PROJECT_REF_PATTERN.test(...)` so a user-created branch name is never logged verbatim. - **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe — closed enums carry no user data — and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below. - **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record. No per-command wiring needed. This gives two flag families their real value automatically, via the boolean-is-safe rule and the choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively: - Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`. diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md new file mode 100644 index 0000000000..229783224c --- /dev/null +++ b/apps/cli/docs/supabase/config/diff.md @@ -0,0 +1,15 @@ +# supabase-config-diff + +Shows the configuration differences between the local `supabase/config.toml` and the effective configuration of a remote project or branch. Read-only: it never modifies the local file or any remote configuration. + +Pass `--project-ref` to compare against 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. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. + +Only platform-managed properties are compared. Local-stack-only sections — `[studio]`, `[analytics]`, `[functions]`, `[edge_runtime]`, port numbers, image version pins, `[db.migrations]`, and similar — have no hosted counterpart and are never reported, whether or not your file declares them. + +Each difference is classified as `update` (the file declares a value that differs remotely), `remote-only` (the remote differs while the file is silent — the shown local value is the schema default a `config push` would write), or `local-only` (the file declares a value the remote did not report). `(unset)` means the local side has no value at all; `(not returned)` means the response did not carry the property. Secret values are never compared — the platform only reports digests — and are listed in a masked-credentials note instead, as are declared properties that `config push` cannot communicate and any block the response omitted entirely. + +Local values are shown as the configuration your file would produce once pushed, not its literal spelling: a duration written as `"1m"` renders as `"1m0s"`, and byte sizes are shown in the units you wrote. + +With `--exit-code`, the command exits `2` when any difference is found, keeping exit `1` for errors — so scripts can distinguish drift from failure. + +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/src/legacy/commands/branches/branches.resolver.ts b/apps/cli/src/legacy/commands/branches/branches.resolver.ts index ff666f9d47..6f66f5c297 100644 --- a/apps/cli/src/legacy/commands/branches/branches.resolver.ts +++ b/apps/cli/src/legacy/commands/branches/branches.resolver.ts @@ -1,7 +1,5 @@ -import { Effect } from "effect"; - -import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { legacyResolveBranchProjectRef as legacyResolveBranchProjectRefShared } from "../../shared/legacy-branch-ref.resolver.ts"; import { LegacyBranchesFindNetworkError, LegacyBranchesFindUnexpectedStatusError, @@ -9,21 +7,6 @@ import { LegacyBranchesGetUnexpectedStatusError, } from "./branches.errors.ts"; -/** - * Project ref pattern shared by every Management-API endpoint that accepts a - * 20-lowercase-letter project reference. Re-export so siblings (e.g. - * `get.handler.ts`) can classify branch-id inputs without re-declaring it. - */ -export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -/** - * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 - * variant including v6/v7 and version 0, matching the established liberal - * acceptance rather than the v1–v5 + variant-1 subset. - */ -export const LEGACY_BRANCH_UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const mapFindError = mapLegacyHttpError({ networkError: LegacyBranchesFindNetworkError, statusError: LegacyBranchesFindUnexpectedStatusError, @@ -39,38 +22,10 @@ const mapGetError = mapLegacyHttpError({ }); /** - * Resolves an arbitrary branch identifier to its project ref: - * - * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. - * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) - * and return `JSON200.ref`. - * 3. Otherwise treat as a branch name under the linked project ref: call - * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return - * `JSON200.project_ref`. - * - * The persistent `--project-ref` is required for path 3 and is passed in by - * the caller (which has already run `LegacyProjectRefResolver` so the linked - * project cache write does not re-fire here). + * The branches family's binding of the shared branch-ref resolver + * (`legacy/shared/legacy-branch-ref.resolver.ts`) to this family's error + * classes. See the shared module for resolution semantics. */ -export const legacyResolveBranchProjectRef = Effect.fnUntraced(function* ( - input: string, - projectRef: string, -) { - if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { - return input; - } - - const api = yield* LegacyPlatformApi; - - if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { - const detail = yield* api.v1 - .getABranchConfig({ branch_id_or_ref: input }) - .pipe(Effect.catch(mapGetError)); - return detail.ref; - } - - const branch = yield* api.v1 - .getABranch({ ref: projectRef, name: input }) - .pipe(Effect.catch(mapFindError)); - return branch.project_ref; -}); +export function legacyResolveBranchProjectRef(input: string, projectRef: string) { + return legacyResolveBranchProjectRefShared(input, projectRef, { mapGetError, mapFindError }); +} diff --git a/apps/cli/src/legacy/commands/branches/get/get.handler.ts b/apps/cli/src/legacy/commands/branches/get/get.handler.ts index 0f8df3340b..bde828c950 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.handler.ts @@ -39,7 +39,7 @@ import { legacyPromptBranchId } from "../branches.prompt.ts"; import { LEGACY_BRANCH_PROJECT_REF_PATTERN, LEGACY_BRANCH_UUID_PATTERN, -} from "../branches.resolver.ts"; +} from "../../../shared/legacy-ref-patterns.ts"; import type { LegacyBranchesGetFlags } from "./get.command.ts"; type BranchDetail = typeof V1GetABranchConfigOutput.Type; diff --git a/apps/cli/src/legacy/commands/config/config.command.ts b/apps/cli/src/legacy/commands/config/config.command.ts index efec9afa22..0c13ba938d 100644 --- a/apps/cli/src/legacy/commands/config/config.command.ts +++ b/apps/cli/src/legacy/commands/config/config.command.ts @@ -1,8 +1,9 @@ import { Command } from "effect/unstable/cli"; +import { legacyConfigDiffCommand } from "./diff/diff.command.ts"; import { legacyConfigPushCommand } from "./push/push.command.ts"; export const legacyConfigCommand = Command.make("config").pipe( Command.withDescription("Manage Supabase project configurations."), Command.withShortDescription("Manage project configurations"), - Command.withSubcommands([legacyConfigPushCommand]), + Command.withSubcommands([legacyConfigDiffCommand, legacyConfigPushCommand]), ); diff --git a/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md new file mode 100644 index 0000000000..3d93a8a939 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md @@ -0,0 +1,155 @@ +# `supabase config diff` + +Read-only comparison between the local `supabase/config.toml` and the effective +configuration the Management API reports for a target project or branch. +Classifies every remotely-managed property as `update` / `remote_only` / +`local_only` (unmanaged local-only properties are never reported). **Never +writes `config.toml` or any remote configuration.** + +## Files Read + +| Path | Format | When | +| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (`loadCliConfig` probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay | +| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | +| `/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | + +## Files Written + +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after run (success **and** failure), if ref resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after run (success **and** failure) | + +**No writes to `supabase/config.toml` or `supabase/config.json`** — covered by +an integration test asserting mtime and contents are unchanged after a run +that finds differences. + +## API Routes + +All Bearer-authenticated, all read-only. + +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--project-ref` is a UUID; needs no linked project (no parent pre-check) | +| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--project-ref` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | + +## Exit Codes + +Drift has its own exit code (`2`), distinct from every failure (`1`), so +`config diff --exit-code` scripts can tell "config drifted" from "token +expired" without parsing output (`terraform plan -detailed-exitcode`'s +convention; `1` stays the CLI-wide failure code). In **text mode only**, exit +2 is preceded by a stderr reason line, `Exiting 2: configuration differences +found (--exit-code).`, so a CI log never shows a bare "exit code 2" with no +explanation; machine mode (`--output-format json/stream-json`) never prints +it, keeping its bytes unchanged. The masked/unmanaged/not-returned-block +caveats never flip the exit code by themselves — only a non-zero +`changeSet.counts.total` (a real `update`/`remote_only`/`local_only` entry) +does. Because +`run.ts` only runs its `afterSuccess` hooks (e.g. the upgrade notice) on exit +code `0`, a `--exit-code` run that exits `2` on drift suppresses that hook, +same as any other non-zero exit. + +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------- | +| `0` | success — including when differences are found, unless `--exit-code` is passed | +| `2` | `--exit-code` passed and at least one difference found | +| `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | +| `1` | missing or malformed `supabase/config.toml`/`config.json` | +| `1` | branch-name `--project-ref` with no linked parent project (`LegacyConfigDiffBranchNotLinkedError`) | +| `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`LegacyConfigDiffParentRefInvalidError`) | +| `1` | unknown branch (branch-name `--project-ref` 404, `LegacyConfigDiffBranchNotFoundError`) | +| `1` | resolved branch has no project ref yet — still provisioning (`LegacyConfigDiffBranchNotReadyError`) | +| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | +| `1` | remote config read failure (network, 401/403/404, or other unexpected status) | + +## Output + +Diagnostics on **stderr**: `Comparing against …` (resolved target + local +scope, i.e. `[remotes.]` or `base config`) before the fetch, then +`Comparison scope: ` listing the blocks the response carried (missing +blocks are called out). The payload is on **stdout**. + +### `--output-format text` + +One block per difference (` [update|remote-only|local-only]` with +`local:`/`remote:` lines; unset renders `(unset)` / `(not returned)`, an +undeclared path with a schema default renders ` (schema default — not +declared in config.toml)`, env-resolved values append `(from env VAR, …)`), +then a summary count line — `No config differences found.` when clean — +followed by a `Note: N block(s) … not returned by the API and … not compared: +…` line when the response omitted a block entirely, a `Note: … (masked by the +API): …` line when the file sets masked secrets, and a `Note: … cannot be +pushed and … not compared: …` line for declared properties push cannot +communicate. Every non-constant string (path segments, env-var names, +remotes/branch names) is sanitized against control characters before +rendering. + +### `--output-format json` / `stream-json` + +`output.success(message, payload)` — the message carries the not-returned/ +masked/unmanaged caveats too, so echoing it never claims "in sync" on a +partial response (e.g. a scoped token returning `auth: {}`) or while masked +values may have drifted. The payload contains `schema_version` (integer +version of THIS payload contract, currently `1`), `config_schema` (the file's +`$schema` URL), `target` (`project_ref`, optional `branch`, `local_scope`), +`scope` (`{present, missing}` block lists — the block set is owned by +`@supabase/config`), `changes[]` (`path` as a SEGMENT ARRAY — a record key may +contain a `.` — plus `class`, `declared`, `local`, `remote`, optional +`env_variables[]`; unset sides are `null`), `masked[]` and `unmanaged[]` +(segment-array paths), and `counts` (per class + `total`). + +### `-o/--output` (legacy machine formats) + +**Not supported.** `config diff` is a net-new TS command with no Go parity +contract (CLI-2156, per Colum). Any `-o`/`--output` value — every +machine-format value AND `pretty` — is rejected outright +(`LegacyConfigDiffOutputFlagUnsupportedError`, exit 1) with: + +``` +the -o/--output flag is not supported by config diff; use --output-format json|stream-json instead. +``` + +Checked FIRST, before any config load, target resolution, or network call — +so a rejected invocation never burns a config read or an API round trip. The +command's own `withLegacyCommandInstrumentation` wiring widens the wrapper's +per-command `-o` enum to the full global choice set so every value (including +`table`/`csv`, which are otherwise only meaningful to `db query`) reaches this +handler-level rejection with its pointed message, rather than the wrapper's +generic pflag-style "invalid argument" rejection. One telemetry consequence of +the widening: a rejected `-o` invocation still emits `cli_command_executed` +(exit code 1, `invalid_input`), whereas other commands' out-of-enum `-o` +values are rejected by the wrapper's enum check before instrumentation fires +and emit nothing. + +## Notes + +- Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. +- **Local operand per target (ADR 0018/0022):** when the resolved target ref matches a + `[remotes.]` block's `project_id`, the local side is that branch's merged + effective config; otherwise the base config. The echoed scope line always says which. +- **Masked credentials:** secret-valued managed properties (the platform returns an HMAC, + never plaintext; the registry's `isSecret` rows) are treated as "present, unknown" — never + reported as differences and never counted for `--exit-code`; they are surfaced via the + masked note / `masked[]`. +- **Values are convergence projections (ADR 0021):** both sides are normalized through + `@supabase/config`'s `fromConfigDocument`/`fromApiProjectConfig`, so a reported "local" + value is what pushing the file would produce hosted (canonicalized durations/byte sizes, + push-gated omissions), not necessarily the file's literal spelling. +- **Partial responses:** a managed property the response does not carry is `local_only` + when the file declares it and silent otherwise; a missing block is called out on the + scope line, AND as a `Note:`/message caveat alongside the masked/unmanaged ones, rather + than treated as an error. diff --git a/apps/cli/src/legacy/commands/config/diff/diff.command.ts b/apps/cli/src/legacy/commands/config/diff/diff.command.ts new file mode 100644 index 0000000000..0788fe5ac0 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.command.ts @@ -0,0 +1,78 @@ +import { Option } from "effect"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command, Flag } from "effect/unstable/cli"; + +import { PROJECT_REF_PATTERN } from "../../../config/legacy-project-ref.service.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { LEGACY_GLOBAL_OUTPUT_FORMATS } from "../../../../shared/legacy/global-flags.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const config = { + // `link`'s settled vocabulary (CLI-2167): 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, 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, + ), + exitCode: Flag.boolean("exit-code").pipe( + Flag.withDescription( + "Exit with status 2 when any difference is found (errors keep exiting 1).", + ), + // Without an explicit default a boolean flag is REQUIRED by the parser, + // making plain `supabase config diff` fail with `required flag(s) + // "exit-code" not set` — pinned by diff.e2e.test.ts, since integration + // tests hand the handler a pre-built flags object and never parse. + Flag.withDefault(false), + ), +} as const; + +export type LegacyConfigDiffFlags = CliCommand.Command.Config.Infer; + +// Exported so integration tests can drive the exact wiring +// `Command.withHandler` uses below (same precedent as `legacyConfigPushHandler`). +export const legacyConfigDiffHandler = (flags: LegacyConfigDiffFlags) => + legacyConfigDiff(flags).pipe( + // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so + // its value is only safe to log verbatim when it is actually ref-shaped + // — a user-created branch name must never reach PostHog. Same guard as + // `link`. + withLegacyCommandInstrumentation({ + flags, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? ["project-ref"] + : [], + // Net-new TS command, no Go parity contract (CLI-2156): the handler + // itself rejects every `-o/--output` value with a message pointing at + // `--output-format`, so the full global choice set — single-sourced + // from the flag's own definition — is declared "allowed" here. + // Otherwise the wrapper's own per-command enum check would reject an + // out-of-set value (e.g. `-o table`) with its generic pflag-style + // message before the handler ever gets a chance to run. + outputFormats: LEGACY_GLOBAL_OUTPUT_FORMATS, + }), + withJsonErrorHandling, + ); + +export const legacyConfigDiffCommand = Command.make("diff", config).pipe( + Command.withDescription( + "Shows configuration differences between supabase/config.toml and a remote project or branch. Read-only: never modifies local or remote configuration.", + ), + Command.withShortDescription("Diff local config against a remote project"), + Command.withExamples([ + { + command: "supabase config diff", + description: "Diff against the linked project", + }, + { + command: "supabase config diff --project-ref staging --exit-code", + description: "Diff against the 'staging' branch, exiting 2 on drift", + }, + ]), + Command.withHandler(legacyConfigDiffHandler), + Command.provide(legacyManagementApiRuntimeLayer(["config", "diff"])), +); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts new file mode 100644 index 0000000000..a9fbe1b7a6 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +// A fake-but-well-formed token (bypasses the auth-layer's own eager +// `SUPABASE_ACCESS_TOKEN` check, same precedent as push's e2e test) so the +// run reaches this command's own handler instead of failing generically on +// "Access token not provided" first. +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("config diff CLI surface", () => { + test("plain `config diff` parses — no boolean flag is accidentally required", async () => { + // Parser-level regression pin (PR #6295 review): a `Flag.boolean` without + // `Flag.withDefault(false)` is a REQUIRED flag, so the help's own first + // example (`supabase config diff`) failed with `required flag(s) + // "exit-code" not set`. Integration tests hand the handler a pre-built + // flags object and never exercise the parser, so this must be pinned at + // the subprocess boundary. + const cwd = await mkdtemp(join(tmpdir(), "supabase-config-diff-e2e-")); + try { + const { stdout, stderr } = await runSupabase(["config", "diff"], { + entrypoint: "legacy", + cwd, + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }); + const combined = `${stdout}\n${stderr}`; + expect(combined).not.toContain("required flag"); + // Positive anchor: this hermetic cwd has no `supabase/config.toml` or + // `supabase/config.json`, so a run that actually reached the handler + // (not just a binary-launch failure) surfaces this exact load error. + // A vacuously-green regression here (e.g. the binary failing to start + // at all) would NOT print this fragment. + expect(combined).toContain( + "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.errors.ts b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts new file mode 100644 index 0000000000..8b8903fdd7 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.errors.ts @@ -0,0 +1,130 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; + +interface NetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + +interface StatusErrorArgs { + readonly status: number; + readonly body: string; + readonly message: string; +} + +/** Local config file missing or unparseable. Aborts before any network call. */ +export class LegacyConfigDiffLoadConfigError extends Data.TaggedError( + "LegacyConfigDiffLoadConfigError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** + * The Go-compat global `-o/--output` flag was passed. `config diff` is a + * net-new TS command with no Go parity contract, so machine output goes + * through `--output-format` only (per Colum on CLI-2156). + */ +export class LegacyConfigDiffOutputFlagUnsupportedError extends Data.TaggedError( + "LegacyConfigDiffOutputFlagUnsupportedError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** `--project-ref` named a branch the parent project does not have. */ +export class LegacyConfigDiffBranchNotFoundError extends Data.TaggedError( + "LegacyConfigDiffBranchNotFoundError", +)<{ 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. Mirrors `LegacyLinkBranchNotLinkedError`'s + * classification (link.errors.ts). + */ +export class LegacyConfigDiffBranchNotLinkedError extends Data.TaggedError( + "LegacyConfigDiffBranchNotLinkedError", +)<{ 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. Mirrors + * `LegacyLinkParentRefInvalidError`'s classification (link.errors.ts). + */ +export class LegacyConfigDiffParentRefInvalidError extends Data.TaggedError( + "LegacyConfigDiffParentRefInvalidError", +)<{ 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 `/v2/projects//config`. Mirrors + * `LegacyLinkBranchNotReadyError`'s classification (link.errors.ts). + */ +export class LegacyConfigDiffBranchNotReadyError extends Data.TaggedError( + "LegacyConfigDiffBranchNotReadyError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; + } +} + +export class LegacyConfigDiffBranchResolveNetworkError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffBranchResolveStatusError extends Data.TaggedError( + "LegacyConfigDiffBranchResolveStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} + +export class LegacyConfigDiffReadNetworkError extends Data.TaggedError( + "LegacyConfigDiffReadNetworkError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class LegacyConfigDiffReadStatusError extends Data.TaggedError( + "LegacyConfigDiffReadStatusError", +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // `/v2/projects/{ref}/config` names a user-selected resource, so a 404 + // means "wrong project ref" — user-actionable, not an external-service + // problem (same rule as the branch-resolve error above and the + // ref-addressed push.errors.ts status errors). + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.ts new file mode 100644 index 0000000000..a02f5e4b65 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.ts @@ -0,0 +1,284 @@ +import { + type ConfigChange, + type ConfigChangeSet, + projectConfigApiBlockKeys, +} from "@supabase/config/internal"; + +import { LEGACY_BRANCH_UUID_PATTERN } from "../../../shared/legacy-ref-patterns.ts"; +import { legacySanitizeInlineName } from "../../../shared/legacy-http-errors.ts"; + +/** + * Pure formatters, payload builders, and input adapters for `config diff` — + * no Effect, no services, unit-testable in isolation. + * + * Every non-constant string interpolated into TEXT output goes through + * `legacySanitizeInlineName`: path segments (`[remotes.*]` names, + * `sms.test_otp` record keys) and env-var names are unconstrained + * user/API-controlled strings, so a hostile value could otherwise emit raw + * ANSI or forge output lines (e.g. a name ending `\nNo config differences + * found.`). JSON output needs no sanitizing — `JSON.stringify` escapes + * control characters. + */ + +/** + * The per-service blocks of the v2 project-config resource — owned by + * `@supabase/config` (derived from its response mirror), never hand-copied + * here, so a block the package learns is never reported "not returned" + * forever. + */ +const REMOTE_CONFIG_BLOCKS: ReadonlyArray = projectConfigApiBlockKeys; + +export interface LegacyConfigDiffScope { + /** Blocks the response's `data.attributes` carried with at least one key. */ + readonly present: ReadonlyArray; + /** Blocks absent from the response — or present but EMPTY, which is how a + * permission-truncated response most plausibly reports a block it could + * not read; claiming an empty block was "compared" would be false. */ + readonly missing: ReadonlyArray; +} + +function isPopulatedBlockRecord(value: unknown): value is Readonly> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length > 0 + ); +} + +/** + * Which per-service blocks the response's `data.attributes` actually carried + * — echoed to the user so a partially-populated response is never mistaken + * for a clean bill of health. + */ +export function legacyConfigDiffScope( + attributes: Readonly>, +): LegacyConfigDiffScope { + const present = REMOTE_CONFIG_BLOCKS.filter((block) => isPopulatedBlockRecord(attributes[block])); + return { + present, + missing: REMOTE_CONFIG_BLOCKS.filter((block) => !present.includes(block)), + }; +} + +export interface LegacyConfigDiffContext { + /** The resolved comparison target's project ref. */ + readonly projectRef: string; + /** The branch name or UUID `--project-ref` carried, when it named one. */ + readonly branch: string | undefined; + /** Matched `[remotes.]` block, when the local operand was merged. */ + readonly appliedRemote: string | undefined; + /** The local file's `$schema` ref (or the current schema URL). */ + readonly configSchema: string; +} + +/** + * Version of the machine payload's own shape — bump when the payload + * contract changes incompatibly. Distinct from the config document's + * `$schema` URL (`config_schema` in the payload), which is user-controlled + * and per-repo. + */ +export const LEGACY_CONFIG_DIFF_PAYLOAD_VERSION = 1; + +const CLASS_LABELS: Record = { + update: "update", + remote_only: "remote-only", + local_only: "local-only", +}; + +function renderValue(value: unknown, absent: string): string { + if (value === undefined) { + return absent; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +/** Display-only join — `ConfigChange.path` is segment-array everywhere else. */ +function renderPath(path: ReadonlyArray): string { + return legacySanitizeInlineName(path.join(".")); +} + +function plural(count: number, singular: string, pluralForm: string): string { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +function localScope(context: LegacyConfigDiffContext): string { + return context.appliedRemote === undefined + ? "base config" + : `[remotes.${legacySanitizeInlineName(context.appliedRemote)}]`; +} + +/** The target-echo line, printed to stderr before any comparison output. */ +export function legacyConfigDiffComparisonLine(context: LegacyConfigDiffContext): string { + // A UUID target is an identifier, not a display name — quoting it as + // `'1111…'` would imply the branch is literally named that. + const projectRef = legacySanitizeInlineName(context.projectRef); + const target = + context.branch === undefined + ? `project ${projectRef}` + : LEGACY_BRANCH_UUID_PATTERN.test(context.branch) + ? `branch ${legacySanitizeInlineName(context.branch)} (project ref ${projectRef})` + : `'${legacySanitizeInlineName(context.branch)}' (branch ${projectRef})`; + return `Comparing against ${target} using ${localScope(context)}\n`; +} + +/** The scope-echo line, printed to stderr once the response arrived. */ +export function legacyConfigDiffScopeLine(scope: LegacyConfigDiffScope): string { + const present = scope.present.length === 0 ? "(none)" : scope.present.join(", "); + const suffix = scope.missing.length === 0 ? "" : ` (not returned: ${scope.missing.join(", ")})`; + return `Comparison scope: ${present}${suffix}\n`; +} + +function maskedCaveat(masked: ReadonlyArray>): string { + return `${plural(masked.length, "credential value", "credential values")} not compared (masked by the API): ${masked.map(renderPath).join(", ")}`; +} + +function unmanagedCaveat(unmanaged: ReadonlyArray>): string { + const phrase = + unmanaged.length === 1 + ? "1 declared property cannot be pushed and was not compared" + : `${unmanaged.length} declared properties cannot be pushed and were not compared`; + return `${phrase}: ${unmanaged.map(renderPath).join(", ")}`; +} + +/** + * Block names come from `REMOTE_CONFIG_BLOCKS` (the schema-derived list), not + * from the response body, so — unlike the masked/unmanaged path lists — there + * is no sanitization concern here; still styled the same way as those two + * caveats for consistency. + */ +function notReturnedCaveat(missing: ReadonlyArray): string { + const phrase = + missing.length === 1 + ? "1 block was not returned by the API and was not compared" + : `${missing.length} blocks were not returned by the API and were not compared`; + return `${phrase}: ${missing.join(", ")}`; +} + +/** + * One-line summary including the not-returned/masked/unmanaged caveats — the + * text-mode count line's caveats also travel with the machine-mode + * `message`, so an agent echoing `.message` never reports "in sync" on a + * partial response (e.g. a scoped token returning `auth: {}`) or a project + * whose masked SMTP password (or unpushable declared value) may have + * drifted. + */ +export function legacyConfigDiffSummaryMessage( + changeSet: ConfigChangeSet, + scope: LegacyConfigDiffScope, +): string { + const total = changeSet.counts.total; + const base = + total === 0 + ? "No config differences found." + : `${plural(total, "config difference", "config differences")} found.`; + const parts = [base]; + if (scope.missing.length > 0) { + parts.push(`${notReturnedCaveat(scope.missing)}.`); + } + if (changeSet.masked.length > 0) { + parts.push(`${maskedCaveat(changeSet.masked)}.`); + } + if (changeSet.unmanaged.length > 0) { + parts.push(`${unmanagedCaveat(changeSet.unmanaged)}.`); + } + return parts.join(" "); +} + +function renderLocal(change: ConfigChange): string { + const value = renderValue(change.local, "(unset)"); + // A populated local value on an undeclared path is the schema default the + // projection materialized — the value a `config push` would write. Say so, + // or "[remote-only]" reads as "this key exists only remotely", which is + // false for anything with a schema default (and the user will grep their + // file for a value that isn't there). + return change.local !== undefined && !change.declared + ? `${value} (schema default — not declared in config.toml)` + : value; +} + +/** Human-readable diff body for text mode (stdout). */ +export function legacyRenderConfigDiffText( + changeSet: ConfigChangeSet, + scope: LegacyConfigDiffScope, +): string { + const lines: Array = []; + for (const change of changeSet.changes) { + lines.push(`${renderPath(change.path)} [${CLASS_LABELS[change.class]}]`); + const env = + change.envVariables === undefined + ? "" + : ` (from env ${legacySanitizeInlineName(change.envVariables.join(", "))})`; + lines.push(` local: ${renderLocal(change)}${env}`); + lines.push(` remote: ${renderValue(change.remote, "(not returned)")}`); + lines.push(""); + } + + const { update, remote_only, local_only, total } = changeSet.counts; + if (total === 0) { + lines.push("No config differences found."); + } else { + lines.push( + `${plural(total, "difference", "differences")} found (${update} update, ${remote_only} remote-only, ${local_only} local-only).`, + ); + } + if (scope.missing.length > 0) { + lines.push(`Note: ${notReturnedCaveat(scope.missing)}`); + } + if (changeSet.masked.length > 0) { + lines.push(`Note: ${maskedCaveat(changeSet.masked)}`); + } + if (changeSet.unmanaged.length > 0) { + lines.push(`Note: ${unmanagedCaveat(changeSet.unmanaged)}`); + } + return `${lines.join("\n")}\n`; +} + +/** + * The structured result for `--output-format json|stream-json` (the only + * machine-output mechanism this command honors — `-o/--output` is rejected + * outright, CLI-2156). Unset sides are explicit `null`s, distinguishable from + * empty values. Paths are segment arrays — a record key (an `sms.test_otp` + * phone number, a `[remotes.*]` name) may itself contain a `.`, so consumers + * must never split a joined string. + */ +export function legacyConfigDiffPayload( + changeSet: ConfigChangeSet, + scope: LegacyConfigDiffScope, + context: LegacyConfigDiffContext, +): Record { + const valueEntry = (key: string, value: unknown): Record => ({ + [key]: value === undefined ? null : value, + }); + + return { + // The payload contract's own version — what a forward-compat consumer + // gates on. The user's `$schema` document reference is `config_schema`: + // user-controlled and per-repo, never a contract signal. + schema_version: LEGACY_CONFIG_DIFF_PAYLOAD_VERSION, + config_schema: context.configSchema, + target: { + project_ref: context.projectRef, + // Omitted (not null) when no branch was targeted — the documented + // contract says optional. + ...(context.branch === undefined ? {} : { branch: context.branch }), + local_scope: + context.appliedRemote === undefined ? "base" : `remotes.${context.appliedRemote}`, + }, + scope: { present: scope.present, missing: scope.missing }, + changes: changeSet.changes.map((change) => ({ + path: change.path, + class: change.class, + declared: change.declared, + ...valueEntry("local", change.local), + ...valueEntry("remote", change.remote), + ...(change.envVariables === undefined ? {} : { env_variables: change.envVariables }), + })), + masked: changeSet.masked, + unmanaged: changeSet.unmanaged, + counts: changeSet.counts, + }; +} diff --git a/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts new file mode 100644 index 0000000000..f79d2b865d --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.format.unit.test.ts @@ -0,0 +1,102 @@ +import type { ConfigChangeSet } from "@supabase/config/internal"; +import { describe, expect, test } from "vitest"; + +import { + legacyConfigDiffScope, + legacyConfigDiffScopeLine, + legacyConfigDiffSummaryMessage, +} from "./diff.format.ts"; + +const EMPTY_CHANGE_SET: ConfigChangeSet = { + changes: [], + masked: [], + unmanaged: [], + counts: { update: 0, remote_only: 0, local_only: 0, total: 0 }, +}; + +describe("legacyConfigDiffScope", () => { + test("lists record blocks the response carried, dropping non-records and empty records", () => { + // An EMPTY block record is how a permission-truncated response most + // plausibly reports a block it could not read — claiming it was + // "compared" while all its keys render (not returned) would be false, + // and with --exit-code that is a permanently red CI no file edit fixes. + expect( + legacyConfigDiffScope({ + api: { max_rows: 5 }, + auth: {}, + database: null, + realtime: [1], + storage: "nope", + }), + ).toEqual({ + present: ["api"], + missing: ["auth", "database", "pooler", "realtime", "storage"], + }); + }); +}); + +describe("legacyConfigDiffScopeLine", () => { + test("calls out blocks the response did not return", () => { + expect( + legacyConfigDiffScopeLine({ + present: ["api", "auth"], + missing: ["database", "pooler", "realtime", "storage"], + }), + ).toBe("Comparison scope: api, auth (not returned: database, pooler, realtime, storage)\n"); + }); + + test("an empty response scope renders (none)", () => { + expect( + legacyConfigDiffScopeLine({ + present: [], + missing: ["api", "auth", "database", "pooler", "realtime", "storage"], + }), + ).toBe( + "Comparison scope: (none) (not returned: api, auth, database, pooler, realtime, storage)\n", + ); + }); +}); + +describe("legacyConfigDiffSummaryMessage", () => { + test("a missing block's caveat travels with the summary message, not just the text renderer", () => { + // Mirrors the hazard documented above: a partial API response (e.g. a + // scoped token returning `auth: {}`) must not report an unqualified "No + // config differences found." in machine `.message` — an agent echoing + // just `.message` would otherwise wrongly claim a full comparison. + expect( + legacyConfigDiffSummaryMessage(EMPTY_CHANGE_SET, { present: ["api"], missing: ["auth"] }), + ).toBe( + "No config differences found. 1 block was not returned by the API and was not compared: auth.", + ); + }); + + test("multiple missing blocks pluralize the caveat", () => { + expect( + legacyConfigDiffSummaryMessage(EMPTY_CHANGE_SET, { + present: [], + missing: ["auth", "storage"], + }), + ).toBe( + "No config differences found. 2 blocks were not returned by the API and were not compared: auth, storage.", + ); + }); + + test("no caveat when every block was returned", () => { + expect( + legacyConfigDiffSummaryMessage(EMPTY_CHANGE_SET, { present: ["api"], missing: [] }), + ).toBe("No config differences found."); + }); + + test("the missing-block caveat travels alongside the masked/unmanaged caveats", () => { + const changeSet: ConfigChangeSet = { + ...EMPTY_CHANGE_SET, + masked: [["auth", "external", "github", "secret"]], + }; + expect( + legacyConfigDiffSummaryMessage(changeSet, { present: ["api"], missing: ["storage"] }), + ).toBe( + "No config differences found. 1 block was not returned by the API and was not compared: storage. " + + "1 credential value not compared (masked by the API): auth.external.github.secret.", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.handler.ts b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts new file mode 100644 index 0000000000..2a1c1deb56 --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.handler.ts @@ -0,0 +1,395 @@ +import { + CLI_CONFIG_SCHEMA_URL, + fromApiProjectConfig, + ProjectConfigParseError, +} from "@supabase/config"; +import { diffProjectConfig, loadCliConfig } from "@supabase/config/internal"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option } 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 { + legacyParentNotLinkedMessage, + legacyParentRefInvalidMessage, + legacyParentRefTypoHint, + legacyResolveLinkedParentRef, + legacyResolveParentScopedProjectRef, +} from "../../../shared/legacy-parent-project-ref.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { legacyResolveBranchProjectRef } from "../../../shared/legacy-branch-ref.resolver.ts"; +import { + LEGACY_BRANCH_PROJECT_REF_PATTERN, + LEGACY_BRANCH_UUID_PATTERN, +} from "../../../shared/legacy-ref-patterns.ts"; +import { + legacySanitizeInlineName, + mapLegacyHttpError, + sanitizeLegacyErrorBody, +} from "../../../shared/legacy-http-errors.ts"; +import { + legacyConfigDiffComparisonLine, + legacyConfigDiffPayload, + legacyConfigDiffScope, + legacyConfigDiffScopeLine, + legacyConfigDiffSummaryMessage, + legacyRenderConfigDiffText, + type LegacyConfigDiffContext, +} from "./diff.format.ts"; +import { + LegacyConfigDiffBranchNotFoundError, + LegacyConfigDiffBranchNotLinkedError, + LegacyConfigDiffBranchNotReadyError, + LegacyConfigDiffBranchResolveNetworkError, + LegacyConfigDiffBranchResolveStatusError, + LegacyConfigDiffLoadConfigError, + LegacyConfigDiffOutputFlagUnsupportedError, + LegacyConfigDiffParentRefInvalidError, + LegacyConfigDiffReadNetworkError, + LegacyConfigDiffReadStatusError, +} from "./diff.errors.ts"; +import type { LegacyConfigDiffFlags } from "./diff.command.ts"; + +const readStatusMessage = (status: number, body: string) => `unexpected status ${status}: ${body}`; + +const mapBranchResolveError = mapLegacyHttpError({ + networkError: LegacyConfigDiffBranchResolveNetworkError, + statusError: LegacyConfigDiffBranchResolveStatusError, + networkMessage: (cause) => `failed to resolve branch: ${cause}`, + statusMessage: readStatusMessage, +}); + +/** + * Purpose-written messages for the config-read status codes a wrong or + * inaccessible ref most plausibly produces; every other status keeps the + * generic `unexpected status N: body` shape. TS-only surface (no Go + * counterpart for this endpoint). + */ +function configReadStatusMessage(status: number, body: string, ref: string): string { + if (status === 401) { + return "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate."; + } + if (status === 403) { + return `Access denied for project ${legacySanitizeInlineName(ref)}: your account does not have permission to view its configuration.`; + } + if (status === 404) { + return `Project ${legacySanitizeInlineName(ref)} not found. Check the project ref, or run \`supabase projects list\` to see the projects you have access to.`; + } + return readStatusMessage(status, body); +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const legacyConfigDiff = Effect.fn("legacy.config.diff")(function* ( + flags: LegacyConfigDiffFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliSettings = yield* LegacyCliSettings; + const processControl = yield* ProcessControl; + const goOutputFlag = yield* LegacyOutputFlag; + + // An empty `--project-ref` value is absent, mirroring the resolver's own rule. + const requested = Option.filter(flags.projectRef, (value) => value.length > 0); + + // Resolved against `cliSettings.workdir` — the same root the project-ref + // resolver and the linked-project cache use — so `--workdir ../other` + // compares `../other`'s config.toml against `../other`'s linked project, + // never the invoking directory's file against another root's project. + // `cause.path` is anchored under the workdir; render it relative so the + // message reads `supabase/config.json` like the family's other messages, + // regardless of invocation cwd. + const relativeConfigPath = (path: string) => + path.startsWith(cliSettings.workdir) + ? path.slice(cliSettings.workdir.length).replace(/^[/\\]/, "") + : path; + + const loadLocalConfig = (projectRef: string | undefined) => + loadCliConfig(cliSettings.workdir, { projectRef, goViperCompat: true }).pipe( + // `cause.path` names the file that actually failed to parse — `loadCliConfig` + // probes `supabase/config.json` before falling back to `supabase/config.toml` + // (`findCliProjectPaths`), so hardcoding the `.toml` name here would mislabel a + // broken `config.json`. + Effect.catchTag( + "CliConfigParseError", + (cause) => + new LegacyConfigDiffLoadConfigError({ + message: `failed to parse ${relativeConfigPath(cause.path)}: ${String(cause.cause)}`, + }), + ), + Effect.catchTag( + "DuplicateRemoteProjectIdError", + (cause) => new LegacyConfigDiffLoadConfigError({ message: cause.message }), + ), + Effect.flatMap((loaded) => + loaded === null + ? Effect.fail( + new LegacyConfigDiffLoadConfigError({ + message: + "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", + }), + ) + : Effect.succeed(loaded), + ), + ); + + // Written once the comparison target is known, so the linked-project cache + // finalizer below only fires for invocations that got that far — matching + // the family pattern of caching exactly the resolved ref. + let resolvedRef: string | undefined; + + yield* Effect.gen(function* () { + // 1. Reject the Go-compat `-o/--output` flag outright, before anything + // else this block does. `config diff` is a net-new TS command with no + // Go parity contract, so machine output goes through `--output-format` + // only (every value, `pretty` included — CLI-2156, per Colum). Checked + // first, ahead of the config load below, so an invalid invocation never + // burns a config read or a network call; still inside this + // `Effect.ensuring`-wrapped block, so telemetry flushes on the rejection + // the same as every other failure here. + if (Option.isSome(goOutputFlag)) { + return yield* new LegacyConfigDiffOutputFlagUnsupportedError({ + message: + "the -o/--output flag is not supported by config diff; use --output-format json|stream-json instead.", + }); + } + + // 2. Load and validate the local config BEFORE any network call or + // target resolution (never writes — this command is read-only by + // contract): a missing file must point at `supabase init` rather than + // the resolver's not-linked error, and a malformed document must not + // burn a branch-resolution round trip. This first load applies no + // `[remotes.*]` overlay — the overlay is keyed by the RESOLVED target + // ref, so a config that declares remotes is reloaded in step 4. + let loaded = yield* loadLocalConfig(undefined); + + // 3. Resolve the comparison target. `--project-ref` accepts a project + // ref, or the name (or UUID) of a branch of the linked project — + // `link`'s settled vocabulary (CLI-2167). A ref-shaped value (exactly 20 + // lowercase letters) is always treated as a project ref. + // + // A UUID target resolves through `GET /v1/branches/{id}` directly, which + // needs no parent ref at all, so it keeps the fully lazy parent + // resolution below — the parent-scoped resolver is never evaluated for + // it, which is exactly what lets it work in an unlinked directory. + // + // A NAME target, by contrast, needs the parent project ref to search + // under, so it is resolved eagerly, BEFORE any spinner starts — + // mirroring `link` (link.handler.ts:198-213): an unlinked directory (or + // a corrupt/stale linked ref) must fail immediately with a link-grade + // error naming the value the user passed, rather than falling through to + // `resolver.resolve`'s interactive project picker rendering under a live + // "Resolving branch..." spinner. + let ref: string; + let branch: string | undefined; + if (Option.isSome(requested) && !LEGACY_BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { + const target = requested.value; + branch = target; + + let parentRef: ReturnType; + if (LEGACY_BRANCH_UUID_PATTERN.test(target)) { + parentRef = legacyResolveParentScopedProjectRef(Option.none()); + } else { + const parent = yield* legacyResolveLinkedParentRef(); + if (parent.kind === "absent") { + return yield* new LegacyConfigDiffBranchNotLinkedError({ + message: legacyParentNotLinkedMessage(target), + }); + } + if (parent.kind === "invalid") { + return yield* new LegacyConfigDiffParentRefInvalidError({ + message: legacyParentRefInvalidMessage(target), + }); + } + parentRef = Effect.succeed(parent.ref); + } + + const resolving = + output.format === "text" ? yield* output.task("Resolving branch...") : undefined; + ref = yield* legacyResolveBranchProjectRef(target, parentRef, { + mapGetError: mapBranchResolveError, + mapFindError: mapBranchResolveError, + }).pipe( + Effect.tapError(() => resolving?.fail() ?? Effect.void), + Effect.catchTag( + "LegacyConfigDiffBranchResolveStatusError", + ( + cause, + ): Effect.Effect< + never, + LegacyConfigDiffBranchNotFoundError | LegacyConfigDiffBranchResolveStatusError + > => + cause.status === 404 + ? Effect.fail( + new LegacyConfigDiffBranchNotFoundError({ + message: `Branch "${legacySanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.${legacyParentRefTypoHint(target)}`, + }), + ) + : Effect.fail(cause), + ), + ); + yield* resolving?.clear() ?? Effect.void; + + // The resolved branch might not have a project ref yet (still + // provisioning) — never let an empty/placeholder ref reach + // `/v2/projects//config` (mirrors link.handler.ts:248-256's guard). + if (!LEGACY_BRANCH_PROJECT_REF_PATTERN.test(ref)) { + return yield* new LegacyConfigDiffBranchNotReadyError({ + message: `Branch "${legacySanitizeInlineName(target)}" has no project ref yet. Wait for it to finish provisioning, then retry.`, + }); + } + } else { + ref = yield* resolver.resolve(requested); + } + resolvedRef = ref; + + // 4. Apply the matching `[remotes.*]` overlay (ADR 0018) now that the + // target ref is known. Only a config whose remotes actually MATCH the + // resolved ref reloads (checked on the already-loaded, env-interpolated + // document) — every other config keeps the step-2 load, so load-time + // deprecation warnings don't repeat. The narrow remaining double-print + // (a matching remote AND a deprecated section) is the price of + // validating before the network call, which is worse to give up. + const remotes = loaded.document?.["remotes"]; + const remoteMatchesRef = + isRecord(remotes) && + Object.values(remotes).some((remote) => isRecord(remote) && remote["project_id"] === ref); + if (remoteMatchesRef) { + loaded = yield* loadLocalConfig(ref); + } + + const context: LegacyConfigDiffContext = { + projectRef: ref, + branch, + appliedRemote: loaded.appliedRemote, + configSchema: loaded.schemaRef ?? CLI_CONFIG_SCHEMA_URL, + }; + yield* output.raw(legacyConfigDiffComparisonLine(context), "stderr"); + + // 5. Fetch the effective remote config (single read-only call) — via + // `executeRaw`, per ADR 0019 rule 2 ("required, not incidental"): the + // generated client's strict Schema.Struct decode drops excess properties + // and rejects unknown enum members (e.g. a new `pool_mode` value), so + // by the time the lenient config mirror ran on its output there would be + // nothing left to be lenient about. The caller owns the status check; + // `fromApiProjectConfig`'s lenient decode owns the body. + const fetching = + output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; + const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.mapError( + (cause) => + new LegacyConfigDiffReadNetworkError({ + message: `failed to read project config: ${cause}`, + }), + ), + ); + if (response.status !== 200) { + const body = sanitizeLegacyErrorBody( + yield* response.text.pipe(Effect.orElseSucceed(() => "")), + ); + yield* fetching?.fail() ?? Effect.void; + return yield* new LegacyConfigDiffReadStatusError({ + status: response.status, + body, + message: configReadStatusMessage(response.status, body, ref), + }); + } + const responseJson = yield* response.json.pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.mapError( + (cause) => + new LegacyConfigDiffReadNetworkError({ + message: `failed to read project config: ${cause}`, + decode: true, + }), + ), + ); + yield* fetching?.clear() ?? Effect.void; + + // 6. Project the response through CLI-2230's convergence normalizer (ADR + // 0021). A response the registry cannot narrow (out-of-domain mapped + // values) is a response problem, not a transport one: + // `ProjectConfigParseError` stays in the typed channel with its own + // `suggestion` and its purpose-built actionability adapter + // (`externalActionabilityByTag` splits caller misuse from genuine + // response problems). Anything else escaping the normalizer would be a + // bug in this package pairing, so it stays a defect. + const remote = yield* Effect.try({ + try: () => fromApiProjectConfig(responseJson), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + cause instanceof ProjectConfigParseError ? Effect.fail(cause) : Effect.die(cause), + ), + ); + + // 7. Classify. The loaded pair carries the raw merged document (declared + // keys) and the env-var origins; `diffProjectConfig` derives the local + // convergence projection from it, so the same `ProjectConfigParseError` + // boundary applies here. + const changeSet = yield* Effect.try({ + try: () => diffProjectConfig({ local: loaded, remote }), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + cause instanceof ProjectConfigParseError ? Effect.fail(cause) : Effect.die(cause), + ), + ); + + const data = isRecord(responseJson) ? responseJson["data"] : undefined; + const scope = legacyConfigDiffScope( + isRecord(data) && isRecord(data["attributes"]) ? data["attributes"] : {}, + ); + yield* output.raw(legacyConfigDiffScopeLine(scope), "stderr"); + + // 8. Emit: `--output-format json|stream-json` structured payload, or + // text. `-o/--output` never reaches here — step 1 rejects it outright, + // so this command has only the one machine-output mechanism. + if (output.format !== "text") { + yield* output.success( + legacyConfigDiffSummaryMessage(changeSet, scope), + legacyConfigDiffPayload(changeSet, scope, context), + ); + } else { + yield* output.raw(legacyRenderConfigDiffText(changeSet, scope)); + } + + // 9. `--exit-code`: differences flip the exit status to 2 after the + // payload is out, without an error envelope corrupting machine output. + // Drift gets its OWN code — every failure exits 1, and a script's + // `config diff --exit-code || alert` must not fire on an expired token + // (`terraform plan -detailed-exitcode`'s 0/1/2 convention, with 1 kept + // for errors to match the rest of the CLI). In TEXT mode only, a stderr + // reason line precedes the exit so a CI log doesn't show only "exit code + // 2" with no explanation (db lint's fail-on reason line is the same + // idea); machine modes stay byte-identical. + if (flags.exitCode && changeSet.counts.total > 0) { + if (output.format === "text") { + yield* output.raw("Exiting 2: configuration differences found (--exit-code).\n", "stderr"); + } + yield* processControl.setExitCode(2); + } + }).pipe( + // Legacy Shell Invariant #1: telemetry flushes on EVERY invocation — + // including load/parse failures and branch-resolution failures — while + // the linked-project cache write needs a resolved ref, so it fires + // exactly when one exists. + Effect.ensuring( + Effect.suspend(() => + resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts new file mode 100644 index 0000000000..221118a97e --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.integration.test.ts @@ -0,0 +1,1253 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + mockContextualAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + legacyJsonResponse, + legacyTransportFailure, + mockLegacyCliSettings, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LEGACY_GLOBAL_OUTPUT_FORMATS } from "../../../../shared/legacy/global-flags.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyConfigDiffHandler } from "./diff.command.ts"; +import { LEGACY_CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; +import { legacyConfigDiff } from "./diff.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-config-diff-int-"); + +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const BRANCH_REF = "cccccccccccccccccccc"; + +function writeConfig(toml: string): string { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "config.toml"); + writeFileSync(path, toml); + return path; +} + +function writeProjectEnv(dotenv: string): void { + const dir = join(tempRoot.current, "supabase"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), dotenv); +} + +/** + * Schema-valid v2 project-config body whose managed values all sit at the + * local schema defaults, so an empty config.toml diffs clean against it. + */ +function v2Response( + opts: { + readonly ref?: string; + readonly attributes?: (attributes: Record) => Record; + } = {}, +) { + const attributes: Record = { + database: { + major_version: 17, + ssl_enforced: false, + network_restrictions: { + entitlement: "allowed", + status: "applied", + allowed_cidrs: [ + { address: "0.0.0.0/0", type: "v4" }, + { address: "::/0", type: "v6" }, + ], + }, + postgres_settings: {}, + }, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 20, + max_client_conn: 100, + }, + // A realistic fresh-project GoTrue record at platform defaults — the + // largest, most transform-heavy mapping surface (durations, inversions, + // unconfigured sentinels, provisioning-default subjects) must run end to + // end and classify CLEANLY against an empty config.toml. An `auth: {}` + // here previously let two classifier blockers through untested. + auth: { + site_url: "http://127.0.0.1:3000", + uri_allow_list: "https://127.0.0.1:3000", + jwt_exp: 3600, + refresh_token_rotation_enabled: true, + security_refresh_token_reuse_interval: 10, + security_manual_linking_enabled: false, + disable_signup: false, + external_anonymous_users_enabled: false, + password_min_length: 6, + password_required_characters: "", + rate_limit_anonymous_users: 30, + rate_limit_token_refresh: 150, + rate_limit_otp: 30, + rate_limit_verify: 30, + rate_limit_sms_sent: 30, + rate_limit_web3: 30, + // GoTrue reports 0 hours for unconfigured session bounds; the mapping + // canonicalizes them to the STRING "0s" (registry unconfiguredValue). + sessions_timebox: 0, + sessions_inactivity_timeout: 0, + external_email_enabled: true, + mailer_secure_email_change_enabled: true, + mailer_autoconfirm: true, + security_update_password_require_reauthentication: false, + mailer_otp_length: 6, + mailer_otp_exp: 3600, + smtp_max_frequency: 1, + smtp_host: null, + // Provisioning-default subject lines (recorded config_auth fixtures). + mailer_subjects_invite: "You have been invited", + mailer_subjects_confirmation: "Confirm Your Signup", + mailer_subjects_recovery: "Reset Your Password", + mailer_subjects_magic_link: "Your Magic Link", + mailer_subjects_email_change: "Confirm Email Change", + mailer_subjects_reauthentication: "Confirm Reauthentication", + mailer_subjects_password_changed_notification: "Your password has been changed", + mailer_subjects_email_changed_notification: "Your email address has been changed", + mailer_subjects_phone_changed_notification: "Your phone number has been changed", + mailer_subjects_identity_linked_notification: "A new identity has been linked", + mailer_subjects_identity_unlinked_notification: "An identity has been unlinked", + mailer_subjects_mfa_factor_enrolled_notification: "A new MFA factor has been enrolled", + mailer_subjects_mfa_factor_unenrolled_notification: "An MFA factor has been unenrolled", + mailer_notifications_password_changed_enabled: false, + mailer_notifications_email_changed_enabled: false, + mailer_notifications_phone_changed_enabled: false, + mailer_notifications_identity_linked_enabled: false, + mailer_notifications_identity_unlinked_enabled: false, + mailer_notifications_mfa_factor_enrolled_enabled: false, + mailer_notifications_mfa_factor_unenrolled_enabled: false, + external_phone_enabled: false, + sms_autoconfirm: false, + sms_max_frequency: 5, + sms_otp_exp: 600, + sms_otp_length: 6, + external_github_enabled: false, + external_github_client_id: "", + mfa_totp_enroll_enabled: false, + mfa_totp_verify_enabled: false, + mfa_phone_enroll_enabled: false, + mfa_phone_verify_enabled: false, + mfa_phone_otp_length: 6, + mfa_phone_template: "Your code is {{ .Code }}", + mfa_phone_max_frequency: 5, + mfa_web_authn_enroll_enabled: false, + mfa_web_authn_verify_enabled: false, + mfa_max_enrolled_factors: 10, + }, + api: { + db_schema: "public,graphql_public", + db_extra_search_path: "public,extensions", + max_rows: 1000, + db_pool_acquisition_timeout: 10, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 200, + max_events_per_second: 100, + max_bytes_per_second: 100000, + max_channels_per_client: 100, + max_joins_per_second: 100, + max_presence_events_per_second: 100, + max_payload_size_in_kb: 100, + presence_enabled: true, + suspend: false, + connection_pool: 10, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 52428800, + features: { + image_transformation: { enabled: false }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: false }, + iceberg_catalog: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2 }, + vector_buckets: { enabled: true, max_buckets: 10, max_indexes: 5 }, + }, + capabilities: { list_v2: true, iceberg_catalog: false }, + upstream_target: "main", + migration_version: "20240701", + database_pool_mode: "transaction", + }, + }; + return { + data: { + type: "project_config", + id: opts.ref ?? LEGACY_VALID_REF, + attributes: opts.attributes === undefined ? attributes : opts.attributes(attributes), + }, + }; +} + +/** V1GetABranch body for the branch-name `--project-ref` lookup. */ +const BRANCH_BY_NAME = { + id: BRANCH_UUID, + name: "staging", + project_ref: BRANCH_REF, + parent_project_ref: LEGACY_VALID_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 the UUID `--project-ref` lookup. */ +const BRANCH_CONFIG = { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "h", + db_port: 5432, +}; + +interface SetupOpts { + readonly toml?: string; + readonly dotenv?: string; + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + readonly v2?: { status: number; body: unknown } | "fail"; + readonly branchByName?: { status: number; body: unknown }; + readonly branchByUuid?: { status: number; body: unknown }; + /** `false` simulates a directory with no linked project. */ + readonly linked?: boolean; + /** Overrides `cliSettings.projectId` directly — takes precedence over `linked`. */ + readonly projectId?: Option.Option; + /** Overrides the process cwd (defaults to the temp workdir). */ + readonly cwd?: string; + readonly analytics?: ReturnType; +} + +function setup(opts: SetupOpts = {}) { + if (opts.toml !== undefined) { + writeConfig(opts.toml); + } + if (opts.dotenv !== undefined) { + writeProjectEnv(opts.dotenv); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + const url = request.url; + if (url.includes("/v2/projects/")) { + if (opts.v2 === "fail") { + return Effect.fail(legacyTransportFailure(request)); + } + const v2 = opts.v2 ?? { status: 200, body: v2Response() }; + return Effect.succeed(legacyJsonResponse(request, v2.status, v2.body)); + } + if (url.includes("/v1/branches/")) { + const b = opts.branchByUuid ?? { status: 200, body: BRANCH_CONFIG }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + if (url.includes("/branches/")) { + const b = opts.branchByName ?? { status: 200, body: BRANCH_BY_NAME }; + return Effect.succeed(legacyJsonResponse(request, b.status, b.body)); + } + return Effect.succeed(legacyJsonResponse(request, 200, {})); + }, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ + workdir: tempRoot.current, + ...(opts.projectId !== undefined + ? { projectId: opts.projectId } + : opts.linked === false + ? { projectId: Option.none() } + : {}), + }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.cwd ?? tempRoot.current }), + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + processControl, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), + }), + ); + return { layer, out, api, telemetry, linkedProjectCache, processControl }; +} + +const noFlags = { + projectRef: Option.none(), + exitCode: false, +}; + +describe("legacy config diff integration", () => { + it.live("reports drift against the linked project without touching the config file", () => { + const { layer, out, processControl, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + const configPath = join(tempRoot.current, "supabase", "config.toml"); + const before = { + mtimeMs: statSync(configPath).mtimeMs, + contents: readFileSync(configPath, "utf8"), + }; + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + + // Never writes: mtime and contents unchanged after a run with differences. + expect(statSync(configPath).mtimeMs).toBe(before.mtimeMs); + expect(readFileSync(configPath, "utf8")).toBe(before.contents); + + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using base config`, + ); + expect(out.stderrText).toContain( + "Comparison scope: api, auth, database, pooler, realtime, storage", + ); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500"); + expect(out.stdoutText).toContain("remote: 1000"); + expect(out.stdoutText).toContain( + "1 difference found (1 update, 0 remote-only, 0 local-only).", + ); + // Differences without --exit-code leave the exit status alone. + expect(processControl.exitCode).toBeUndefined(); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("a clean config produces the success message and exit 0 even with --exit-code", () => { + const { layer, out, processControl } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--exit-code sets exit 2 when differences are found", () => { + // Drift gets its own exit code (2) so scripts can tell it from failure + // (1) — `config diff --exit-code || alert` must not fire on an expired + // token. + const { layer, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(processControl.exitCode).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.live("--exit-code in text mode prints a stderr reason line before exiting 2", () => { + // A CI log that shows only "exit code 2" with no explanation is a + // regression — the reason line makes the drift-vs-failure distinction + // visible in the log itself, without touching machine-format bytes. + const { layer, out, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stderrText).toContain("Exiting 2: configuration differences found (--exit-code)."); + expect(processControl.exitCode).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.live("--exit-code in json mode exits 2 without the stderr reason line", () => { + const { layer, out, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stderrText).not.toContain("Exiting 2"); + expect(processControl.exitCode).toBe(2); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared properties the response does not carry are local_only", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth]\nsite_url = "https://local.example.com"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => { + // Drop site_url from the otherwise-complete auth record so the + // response genuinely does not carry the declared property. + const { site_url: _siteUrl, ...auth } = attributes["auth"] as Record; + return { ...attributes, auth }; + }, + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("auth.site_url [local-only]"); + expect(out.stdoutText).toContain('local: "https://local.example.com"'); + expect(out.stdoutText).toContain("remote: (not returned)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("env()-resolved values compare resolved and name the variable on drift", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = "env(PGRST_MAX_ROWS)"\n', + dotenv: "PGRST_MAX_ROWS=500\n", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + expect(out.stdoutText).toContain("local: 500 (from env PGRST_MAX_ROWS)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("declared secrets are masked, not compared, and never count for --exit-code", () => { + const { layer, out, processControl } = setup({ + toml: [ + 'project_id = "test"', + "[auth.external.github]", + "enabled = true", + 'client_id = "id"', + 'secret = "env(GITHUB_SECRET)"', + "", + ].join("\n"), + dotenv: "GITHUB_SECRET=shh\n", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + external_github_enabled: true, + external_github_client_id: "id", + // The platform reports secret fields as HMAC digests, never + // plaintext — the digest must not surface either. + external_github_secret: "v1,whmac-sha256-digest-of-the-secret", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 credential value not compared (masked by the API): auth.external.github.secret", + ); + // The secret STRING never leaks — neither the local plaintext resolved + // from the env var nor the API-reported HMAC digest, on either stream. + // Pins the "secrets never leak" claim against formatter changes. + const everything = out.stdoutText + out.stderrText; + expect(everything).not.toContain("shh"); + expect(everything).not.toContain("whmac-sha256"); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("secret strings never reach the machine payload either", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[auth.external.github]", + "enabled = true", + 'client_id = "id"', + 'secret = "env(GITHUB_SECRET)"', + "", + ].join("\n"), + dotenv: "GITHUB_SECRET=shh\n", + format: "json", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + external_github_enabled: true, + external_github_client_id: "id", + external_github_secret: "v1,whmac-sha256-digest-of-the-secret", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const serialized = JSON.stringify(success); + expect(serialized).not.toContain("shh"); + expect(serialized).not.toContain("whmac-sha256"); + // The message itself carries the masked caveat, so `.message` echoers + // never claim full sync. + expect(success?.message).toContain("masked by the API"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a matching [remotes.*] block becomes the local operand", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[api]", + "max_rows = 500", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + "max_rows = 1000", + "", + ].join("\n"), + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain( + `Comparing against project ${LEGACY_VALID_REF} using [remotes.staging]`, + ); + // The merged branch operand (max_rows = 1000) matches the remote, so the + // base config's 500 must NOT surface as drift. + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("a branch-named --project-ref resolves via the parent project", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }); + expect(out.stderrText).toContain( + `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, + ); + const urls = api.requests.map((request) => request.url); + expect( + urls.some((url) => url.includes(`/v1/projects/${LEGACY_VALID_REF}/branches/staging`)), + ).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("branch-name resolution uses the linked PARENT, not a branch ref in project-ref", () => { + // After `link `, `.temp/project-ref` holds the BRANCH's own ref, + // and the parent-scoped branches endpoint rejects branch refs — the + // parent must come from the parent-scoped resolver (which prefers the + // linked-project.json parent recovery), never the file verbatim. + const temp = join(tempRoot.current, "supabase", ".temp"); + mkdirSync(temp, { recursive: true }); + writeFileSync(join(temp, "project-ref"), BRANCH_REF); + writeFileSync(join(temp, "linked-project.json"), JSON.stringify({ ref: LEGACY_VALID_REF })); + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + linked: false, + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }); + const urls = api.requests.map((request) => request.url); + expect( + urls.some((url) => url.includes(`/v1/projects/${LEGACY_VALID_REF}/branches/staging`)), + ).toBe(true); + expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("a UUID --project-ref resolves directly, even in an unlinked directory", () => { + // The UUID endpoint (`GET /v1/branches/{id}`) does not use a parent + // project ref, so the lookup must not demand a linked directory — the + // parent is only resolved (lazily) for branch-NAME lookups. + const { layer, api, out } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + linked: false, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some(BRANCH_UUID) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + // A UUID is an identifier, not a display name — never quoted as one. + expect(out.stderrText).toContain( + `Comparing against branch ${BRANCH_UUID} (project ref ${BRANCH_REF})`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("a ref-shaped --project-ref never touches the branches API", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some(BRANCH_REF) }); + const urls = api.requests.map((request) => request.url); + expect(urls.some((url) => url.includes("/branches/"))).toBe(false); + expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an unknown branch fails with a branches-list suggestion", () => { + const { layer, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 404, body: { message: "not found" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("ghost") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffBranchNotFoundError"); + expect(rendered).toContain('Branch \\"ghost\\" not found'); + expect(rendered).toContain("supabase branches list"); + // Legacy Shell Invariant #1: telemetry flushes on failure too; the + // linked-project cache stays untouched because no target ref resolved. + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("a non-404 branch lookup failure keeps its status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 500, body: { message: "boom" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffBranchResolveStatusError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a missing config file points at supabase init before any resolution", () => { + const { layer, telemetry, api } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffLoadConfigError"); + // `loadCliConfig` probes both filenames (`findCliProjectPaths`), so the + // message must not claim only `config.toml` was checked. + expect(rendered).toContain("supabase/config.toml or supabase/config.json: file not found"); + expect(rendered).toContain("supabase init"); + // The load runs before any network call, and telemetry still flushes. + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a malformed config aborts before any network call, even with a branch target", () => { + // A broken TOML must not burn a branch-resolution round trip — the local + // document is parsed and validated first. + const { layer, api, telemetry } = setup({ toml: "not [valid toml\n" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + // The message names the REAL file that failed to parse (`cause.path`), + // not a hardcoded `.toml` guess — rendered workdir-relative so it reads + // the same regardless of invocation cwd. + expect(JSON.stringify(exit)).toContain(`failed to parse ${join("supabase", "config.toml")}`); + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("a malformed config file fails as a parse error", () => { + const { layer } = setup({ toml: "not [valid toml\n" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain(`failed to parse ${join("supabase", "config.toml")}`); + }).pipe(Effect.provide(layer)); + }); + + it.live("duplicate [remotes.*] project_ids abort the load", () => { + const { layer } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.a]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.b]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + ].join("\n"), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffLoadConfigError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config transport failure maps to the read network error", () => { + const { layer, telemetry } = setup({ toml: 'project_id = "test"\n', v2: "fail" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + // Telemetry still flushes on failure via Effect.ensuring. + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("an out-of-domain mapped value in the response keeps its typed parse error", () => { + // Wire-valid but semantically impossible: the registry's typed throw + // (ADR 0021 API-arm family) stays in the typed channel as + // ProjectConfigParseError, keeping its upstream suggestion and its + // purpose-built actionability adapter instead of masquerading as a + // network failure. + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + storage: { + ...(attributes["storage"] as Record), + file_size_limit: -1, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("ProjectConfigParseError"); + expect(rendered).toContain("Could not read the project config"); + // The upstream remedy survives to the renderer instead of being + // stringified away. + expect(rendered).toContain("suggestion"); + }).pipe(Effect.provide(layer)); + }); + + it.live("an unknown enum value in the response degrades instead of failing", () => { + // ADR 0019 rule 2: the fetch goes through executeRaw, so the generated + // contract's closed enums (pooler.pool_mode is three literals there) + // never gate the response — a new platform enum member reaches the + // lenient config mirror, whose registry row omits the unrecognized value + // rather than failing the whole diff. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + pooler: { + ...(attributes["pooler"] as Record), + pool_mode: "burst_v9", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("a remote config error status maps to the read status error", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 403, body: { message: "forbidden" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffReadStatusError"); + // 403 gets a purpose-written message naming the project instead of the + // raw `unexpected status 403: {"message":"forbidden"}` body dump. + expect(rendered).toContain("Access denied"); + expect(rendered).toContain(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("a 401 on the config read points at re-authenticating", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 401, body: { message: "unauthorized" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffReadStatusError"); + expect(rendered).toContain("supabase login"); + }).pipe(Effect.provide(layer)); + }); + + it.live("a 404 on the config read names the sanitized ref and suggests projects list", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 404, body: { message: "not found" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffReadStatusError"); + expect(rendered).toContain(LEGACY_VALID_REF); + expect(rendered).toContain("supabase projects list"); + }).pipe(Effect.provide(layer)); + }); + + it.live("other config-read statuses keep the generic unexpected-status message", () => { + const { layer } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 500, body: { message: "boom" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain('unexpected status 500: {\\"message\\":\\"boom\\"}'); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format json emits the structured change set", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success).toBeDefined(); + expect(success?.message).toContain("1 config difference found."); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ + project_ref: LEGACY_VALID_REF, + local_scope: "base", + }); + // `schema_version` is the PAYLOAD contract's version; the user's + // `$schema` document reference travels separately as `config_schema`. + expect(data["schema_version"]).toBe(LEGACY_CONFIG_DIFF_PAYLOAD_VERSION); + expect(data["schema_version"]).toBe(1); + expect(typeof data["config_schema"]).toBe("string"); + expect(data["scope"]).toEqual({ + present: ["api", "auth", "database", "pooler", "realtime", "storage"], + missing: [], + }); + expect(data["changes"]).toEqual([ + { path: ["api", "max_rows"], class: "update", declared: true, local: 500, remote: 1000 }, + ]); + expect(data["counts"]).toEqual({ update: 1, remote_only: 0, local_only: 0, total: 1 }); + expect(data["masked"]).toEqual([]); + expect(data["unmanaged"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--output-format stream-json reports zero differences as a success result", () => { + const { layer, out } = setup({ toml: 'project_id = "test"\n', format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.message).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("every -o/--output value is rejected outright before any work happens", () => { + // Net-new TS command, no Go parity contract: every value the shared + // global `-o`/`--output` flag can carry — the machine formats, `pretty`, + // and the `db query`-only `table`/`csv` values alike — is rejected with + // the exact message pointing at `--output-format` (CLI-2156, per Colum), + // before any config load, target resolution, or network call. + // Iterates the flag's own choice list, so a value added to the global + // flag automatically extends this rejection coverage. + const values = LEGACY_GLOBAL_OUTPUT_FORMATS; + const run = (goOutput: (typeof values)[number]) => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + goOutput, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffOutputFlagUnsupportedError"); + expect(rendered).toContain( + "the -o/--output flag is not supported by config diff; use --output-format json|stream-json instead.", + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }; + return Effect.gen(function* () { + for (const value of values) { + yield* run(value); + } + }); + }); + + it.live("a fetch failure in json mode still maps cleanly without a spinner", () => { + const { layer } = setup({ toml: 'project_id = "test"\n', v2: "fail", format: "json" }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff(noFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("LegacyConfigDiffReadNetworkError"); + }).pipe(Effect.provide(layer)); + }); + + it.live("json payload carries the remotes scope and env variable annotations", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "[remotes.staging.api]", + 'max_rows = "env(PGRST_MAX_ROWS)"', + "", + ].join("\n"), + dotenv: "PGRST_MAX_ROWS=500\n", + format: "json", + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as Record; + expect(data["target"]).toMatchObject({ local_scope: "remotes.staging" }); + expect(data["changes"]).toEqual([ + { + path: ["api", "max_rows"], + class: "update", + declared: true, + local: 500, + remote: 1000, + env_variables: ["PGRST_MAX_ROWS"], + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("remote-only drift renders (unset) locals distinguishably from empty ones", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + database: { + ...(attributes["database"] as Record), + postgres_settings: { work_mem: "64MB" }, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("db.settings.work_mem [remote-only]"); + expect(out.stdoutText).toContain("local: (unset)"); + expect(out.stdoutText).toContain('remote: "64MB"'); + }).pipe(Effect.provide(layer)); + }); + + it.live("remote-only drift on a defaulted path shows the local schema default", () => { + // The someone-changed-it-in-the-dashboard case: the file never declares + // api.max_rows, the remote reports 250, and a `config push` would write + // the schema default 1000 over it — the output must say so instead of + // implying the key exists only remotely. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { ...(attributes["api"] as Record), max_rows: 250 }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [remote-only]"); + expect(out.stdoutText).toContain( + "local: 1000 (schema default — not declared in config.toml)", + ); + expect(out.stdoutText).toContain("remote: 250"); + }).pipe(Effect.provide(layer)); + }); + + it.live("the config file is read relative to --workdir, not the invoking directory", () => { + // `--workdir ../other` must compare `../other`'s config.toml against + // `../other`'s linked project — reading the invoking directory's file + // would silently diff the WRONG config (the resolver and linked-project + // cache already use the workdir). The ambient cwd here points somewhere + // with no supabase/ directory at all; only cliSettings.workdir knows + // where the project lives. + const elsewhere = join(tempRoot.current, "unrelated-cwd"); + mkdirSync(elsewhere, { recursive: true }); + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + cwd: elsewhere, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("api.max_rows [update]"); + }).pipe(Effect.provide(layer)); + }); + + it.live("hostile names cannot inject ANSI or forge output lines in text mode", () => { + // Path segments are attacker-influenced ([remotes.*] names and + // sms.test_otp keys are unconstrained TOML keys) — a name carrying an + // escape byte or newline must not reach the terminal raw, where it could + // recolor output or append a fake "No config differences found." line. + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + '[remotes."evil\\u001B[31mred"]', + `project_id = "${LEGACY_VALID_REF}"`, + '[remotes."evil\\u001B[31mred".api]', + "max_rows = 500", + "", + ].join("\n"), + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain("[remotes.evil[31mred]"); + expect(out.stderrText).not.toContain("\u001b"); + expect(out.stdoutText).not.toContain("\u001b"); + }).pipe(Effect.provide(layer)); + }); + + it.live("an empty block record is reported not-returned, not silently compared", () => { + // A permission-truncated `auth: {}` is schema-valid; claiming it was + // compared while every auth key silently vanishes would make a red CI + // unfixable by any file edit. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ attributes: (attributes) => ({ ...attributes, auth: {} }) }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stderrText).toContain( + "Comparison scope: api, database, pooler, realtime, storage (not returned: auth)", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("a missing block's not-compared caveat travels with the machine `.message`", () => { + // A partial API response (e.g. a scoped token returning `auth: {}`) must + // not report an unqualified "No config differences found." — an agent + // echoing just `.message` would otherwise wrongly claim a full compare. + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + format: "json", + v2: { + status: 200, + body: v2Response({ attributes: (attributes) => ({ ...attributes, auth: {} }) }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.message).toBe( + "No config differences found. 1 block was not returned by the API and was not compared: auth.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("the same missing-block caveat renders as a Note in text mode", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + v2: { + status: 200, + body: v2Response({ attributes: (attributes) => ({ ...attributes, auth: {} }) }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 block was not returned by the API and was not compared: auth", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("a declared path push cannot communicate surfaces in the unmanaged note", () => { + // auth.oauth_server is dropped from the local projection entirely (push + // has no oauth_server handling), so a declared `enabled = true` + // disagreeing with the remote's default `false` cannot be a change entry + // — but it must not vanish silently either. + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth.oauth_server]\nenabled = true\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { oauth_server_enabled: false }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* legacyConfigDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + expect(out.stdoutText).toContain( + "Note: 1 declared property cannot be pushed and was not compared: auth.oauth_server.enabled", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "a branch-NAME --project-ref in an unlinked dir fails immediately, naming the value", + () => { + // Before the fix this fell through to `resolver.resolve`'s interactive + // project picker under a live "Resolving branch..." spinner and, in a + // non-interactive environment, surfaced the generic "Cannot find + // project ref. Have you run supabase link?" instead of a link-grade + // error naming the value the user actually passed. + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n', + linked: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ + ...noFlags, + projectRef: Option.some("somebranch"), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffBranchNotLinkedError"); + expect(rendered).toContain('\\"somebranch\\"'); + // Fails purely from local file/env state — no branches lookup, no + // project-picker prompt, no config-read call. + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a branch-NAME --project-ref with a corrupt linked ref reports it as invalid", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + projectId: Option.some("not-a-valid-ref"), + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ + ...noFlags, + projectRef: Option.some("somebranch"), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffParentRefInvalidError"); + expect(rendered).toContain('\\"somebranch\\"'); + expect(rendered).toContain("Relink the parent project"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("a resolved branch with no project ref yet fails with a not-ready error", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n', + branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyConfigDiff({ + ...noFlags, + projectRef: Option.some("staging"), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffBranchNotReadyError"); + expect(rendered).toContain("has no project ref yet"); + // The placeholder ref never reaches the config-read call. + expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("legacy config diff telemetry wiring", () => { + // Drives the exact `Command.withHandler` wiring (legacyConfigDiffHandler) + // rather than the bare handler: the safeFlags guard lives in the wiring, + // and nothing validates `--project-ref` before instrumentation fires. + const wiringLayer = (analytics: ReturnType, projectRef: string) => + Layer.mergeAll( + setup({ toml: 'project_id = "test"\n', analytics }).layer, + commandRuntimeLayer(["config", "diff"]), + Stdio.layerTest({ + args: Effect.succeed(["config", "diff", "--project-ref", projectRef]), + }), + ); + + it.live("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit( + legacyConfigDiffHandler({ projectRef: Option.some(LEGACY_VALID_REF), exitCode: false }), + ); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ "project-ref": LEGACY_VALID_REF }); + }).pipe(Effect.provide(wiringLayer(analytics, LEGACY_VALID_REF))); + }); + + it.live("redacts a branch-name-shaped --project-ref", () => { + // `--project-ref` accepts branch names too (CLI-2167 vocabulary) — a + // user-created branch name must never reach PostHog verbatim. + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit( + legacyConfigDiffHandler({ projectRef: Option.some("staging"), exitCode: false }), + ); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ "project-ref": "" }); + }).pipe(Effect.provide(wiringLayer(analytics, "staging"))); + }); +}); + +describe("legacy config diff -o/--output wrapper wiring", () => { + // The wrapper's own per-command `-o` enum check (`withLegacyCommandInstrumentation`) + // defaults to the resource-command set (`env|pretty|json|toml|yaml`), which + // excludes `table`/`csv` — without `diff.command.ts`'s `outputFormats` + // override widening that set to the full global choice list, a `-o table` + // invocation would be rejected by the wrapper's generic pflag-style + // message before ever reaching this command's own, more specific + // rejection. Drives the real `legacyConfigDiffHandler` wiring (not the bare + // handler) so this actually exercises that override. + it.live("-o table reaches this command's own message, not the wrapper's generic one", () => { + const { layer, api } = setup({ toml: 'project_id = "test"\n', goOutput: "table" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyConfigDiffHandler(noFlags)); + expect(Exit.isFailure(exit)).toBe(true); + const rendered = JSON.stringify(exit); + expect(rendered).toContain("LegacyConfigDiffOutputFlagUnsupportedError"); + expect(rendered).not.toContain("LegacyInvalidOutputFormatError"); + expect(api.requests).toHaveLength(0); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + commandRuntimeLayer(["config", "diff"]), + Stdio.layerTest({ args: Effect.succeed(["config", "diff", "-o", "table"]) }), + ), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts new file mode 100644 index 0000000000..94737817ef --- /dev/null +++ b/apps/cli/src/legacy/commands/config/diff/diff.live.test.ts @@ -0,0 +1,25 @@ +import { expect } from "vitest"; + +import { requireLiveSuccess, test } from "../../../../../tests/helpers/live.ts"; + +// Golden path only: the one thing mocks cannot prove is the real +// `GET /v2/projects/{ref}/config` response shape (the GoTrue-keyed auth +// record especially) decoding and classifying cleanly. Branch coverage lives +// in diff.integration.test.ts. The `workspace` fixture behind `cli` is a +// fresh `supabase init` project directory. +test("diffs a freshly-initialized config against the project", async ({ cli, project }) => { + const result = await cli(["config", "diff", "--project-ref", project.ref]); + expect(`${result.stdout}${result.stderr}`).not.toContain("Unauthorized"); + expect(result.stderr).toContain(`Comparing against project ${project.ref} using base config`); + expect(result.stderr).toContain("Comparison scope:"); + // The GoTrue-keyed auth record — the one surface mocks cannot prove — must + // classify CLEANLY against a fresh config: the platform's reports of + // unconfigured state (session zeros canonicalized to "0s" via + // unconfiguredValue, platform-rendered mailer subjects via platformRendered, + // disabled notification toggles) are suppressed by the registry's declared + // baselines, not flagged as drift. Asserting only exit 0 here would let that noise through silently. + const authChangeLines = result.stdout.split("\n").filter((line) => line.startsWith("auth.")); + expect(authChangeLines, result.stdout).toEqual([]); + // Read-only success regardless of drift (no --exit-code passed). + requireLiveSuccess(result, "config diff"); +}); 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 05854892e9..9fee2801b2 100644 --- a/apps/cli/src/legacy/commands/config/push/push.command.ts +++ b/apps/cli/src/legacy/commands/config/push/push.command.ts @@ -1,5 +1,7 @@ -import { Layer } from "effect"; +import { Layer, Option } from "effect"; import { Command, Flag } from "effect/unstable/cli"; + +import { PROJECT_REF_PATTERN } from "../../../config/legacy-project-ref.service.ts"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -17,6 +19,25 @@ const config = { export type LegacyConfigPushFlags = CliCommand.Command.Config.Infer; +// Exported so integration tests can drive the exact wiring +// `Command.withHandler` uses below (same precedent as `legacyLinkHandler`). +export const legacyConfigPushHandler = (flags: LegacyConfigPushFlags) => + legacyConfigPush(flags).pipe( + // Nothing validates `--project-ref` before the instrumentation fires, so + // its value is only safe to log verbatim when it is actually ref-shaped — + // an arbitrary string (a typo, a value pasted from the wrong clipboard) + // must reach PostHog as "". Same guard as `link`/`config diff` + // (documented safe list in apps/cli/CLAUDE.md). + withLegacyCommandInstrumentation({ + flags, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? ["project-ref"] + : [], + }), + withJsonErrorHandling, + ); + export const legacyConfigPushCommand = Command.make("push", config).pipe( Command.withDescription("Pushes local config.toml to the linked project."), Command.withShortDescription("Push local config to linked project"), @@ -30,11 +51,6 @@ export const legacyConfigPushCommand = Command.make("push", config).pipe( description: "Push local config to a specific project", }, ]), - Command.withHandler((flags) => - legacyConfigPush(flags).pipe( - withLegacyCommandInstrumentation({ flags }), - withJsonErrorHandling, - ), - ), + Command.withHandler(legacyConfigPushHandler), Command.provide(Layer.mergeAll(legacyManagementApiRuntimeLayer(["config", "push"]), stdinLayer)), ); 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 f493bddec9..15dbd12eb2 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -4,12 +4,12 @@ import { loadCliConfig } from "@supabase/config/internal"; import { Effect, FileSystem, 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 { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { legacyAssertDecryptableSecrets, legacyLoadProjectEnv, @@ -89,9 +89,9 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const output = yield* Output; const api = yield* LegacyPlatformApi; const resolver = yield* LegacyProjectRefResolver; + const cliSettings = yield* LegacyCliSettings; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; - const runtimeInfo = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before @@ -101,7 +101,11 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // (walking up, same as `loadCliConfig` below and the workdir change // before config load), so a push from a subdirectory still reads the // project root's `supabase/.env`. - const projectRoot = (yield* findCliProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; + // Resolved against `cliSettings.workdir` — the same root the project-ref + // resolver and the linked-project cache use — so `--workdir ../other` + // pushes `../other`'s config.toml, never the invoking directory's file to + // another root's linked project. + const projectRoot = (yield* findCliProjectRoot(cliSettings.workdir)) ?? cliSettings.workdir; const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // dotenvx private keys for decrypting `encrypted:` secrets, from the shell @@ -132,7 +136,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // 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(runtimeInfo.cwd, { + const loaded = yield* loadCliConfig(cliSettings.workdir, { projectRef: ref, goViperCompat: true, }).pipe( 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 99b04f7ca8..3558b1a532 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 @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockContextualAnalytics, + mockOutput, +} from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, legacyJsonResponse, @@ -19,7 +23,9 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { legacyConfigPush } from "./push.handler.ts"; +import { legacyConfigPushHandler } from "./push.command.ts"; const tempRoot = useLegacyTempWorkdir("supabase-config-push-int-"); @@ -88,6 +94,10 @@ function setup(opts: { readonly pipedAnswers?: ReadonlyArray; /** Working directory the handler runs from; defaults to the temp project root. */ readonly runtimeCwd?: string; + /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ + readonly workdir?: string; + /** Analytics mock for tests asserting on captured telemetry events. */ + readonly analytics?: ReturnType; }) { writeConfig(opts.toml); const routes = opts.routes ?? {}; @@ -141,11 +151,12 @@ function setup(opts: { buildLegacyTestRuntime({ out, api, - cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), + cliSettings: mockLegacyCliSettings({ workdir: opts.workdir ?? tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), }), mockStdin( opts.stdinIsTty ?? true, @@ -410,11 +421,11 @@ project_id = "abcdefghijklmnopqrst" ); }); - it.live("loads config-push env from the project root when run from a subdirectory", () => { - // The workdir change moves to the project root before config load, so a - // SUPABASE_YES in /supabase/.env auto-confirms even when invoked - // from a subdir. The env load must walk up like loadCliConfig, not - // use the raw cwd. + it.live("loads config-push env from the project root when --workdir names a subdirectory", () => { + // The handler resolves against cliSettings.workdir, so a `--workdir` + // pointing INSIDE the project must still walk up to the project root for + // the env load (like loadCliConfig) — a SUPABASE_YES in + // /supabase/.env auto-confirms even then. const prev = process.env["SUPABASE_YES"]; delete process.env["SUPABASE_YES"]; const sub = join(tempRoot.current, "nested", "dir"); @@ -423,7 +434,7 @@ project_id = "abcdefghijklmnopqrst" toml: API_ONLY_TOML, stdinIsTty: false, pipedAnswers: ["n"], - runtimeCwd: sub, + workdir: sub, routes: { postgrestGet: { status: 200, body: POSTGREST_DISABLED }, postgresGet: { status: 200, body: {} }, @@ -1007,3 +1018,39 @@ enabled = true }).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, + // and nothing validates `--project-ref` before instrumentation fires. + const wiringLayer = (analytics: ReturnType, projectRef: string) => + Layer.mergeAll( + setup({ toml: API_ONLY_TOML, yes: true, analytics }).layer, + commandRuntimeLayer(["config", "push"]), + Stdio.layerTest({ + args: Effect.succeed(["config", "push", "--project-ref", projectRef]), + }), + ); + + it.live("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { + const analytics = mockContextualAnalytics(); + const ref = "abcdefghijklmnopqrst"; + return Effect.gen(function* () { + yield* Effect.exit(legacyConfigPushHandler({ projectRef: Option.some(ref) })); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ "project-ref": ref }); + }).pipe(Effect.provide(wiringLayer(analytics, ref))); + }); + + it.live("redacts a --project-ref value that is not ref-shaped", () => { + // An arbitrary string (typo, wrong clipboard paste) may contain user + // data — it must reach PostHog as the redaction sentinel, never verbatim. + const analytics = mockContextualAnalytics(); + const value = "s3cret-paste-mistake"; + return Effect.gen(function* () { + yield* Effect.exit(legacyConfigPushHandler({ projectRef: Option.some(value) })); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ "project-ref": "" }); + }).pipe(Effect.provide(wiringLayer(analytics, value))); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 04fa4c2b0f..41ea2d4205 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -8,8 +8,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; -import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, legacyJsonResponse, @@ -19,7 +17,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { legacyContainerRuntimeNotFoundMessage } from "../../../shared/legacy-container-cli.ts"; @@ -126,28 +124,6 @@ function mockDockerRunSpawnFailure() { const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); -// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. -// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't -// merge this context (most callers don't need it). -function mockContextualAnalytics() { - const captured: Array<{ event: string; properties: Record }> = []; - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ event, properties: { ...context, ...properties } }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - return { layer, captured }; -} const baseFlags: LegacyFunctionsDownloadFlags = { functionName: Option.some("hello-world"), projectRef: Option.none(), diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index 4b48b26b83..bb9c19d841 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -23,6 +23,9 @@ import { } from "../../../shared/telemetry/event-catalog.ts"; import { type LegacyCachedLinkedProject, + legacyParentNotLinkedMessage, + legacyParentRefInvalidMessage, + legacyParentRefTypoHint, legacyParseCachedLinkedProject, legacyResolveLinkedParentRef, } from "../../shared/legacy-parent-project-ref.ts"; @@ -122,26 +125,6 @@ const mapApiKeysError = legacyMapTenantApiKeysError({ // own 60s×5-retry defaults (PR #6168 review). const LEGACY_LINK_CACHE_CORRELATION_TIMEOUT = Duration.seconds(5); -/** - * A value made entirely of lowercase letters (but not 20 of them, or it would - * already have been treated as a ref) is a plausible ref typo. Appended to - * both branch-not-found messages and the not-linked message (CLI-2167). - */ -function legacyLinkTypoHint(value: string): string { - if (!/^[a-z]+$/.test(value)) return ""; - return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; -} - -function legacyLinkNotLinkedMessage(value: string): string { - return ( - `Cannot resolve "${value}": it is not a project ref (refs are exactly 20 lowercase letters, ` + - "like `abcdefghijklmnopqrst`), so it was treated as a branch name — but no project is linked " + - "to search for branches.\n" + - " If it is a branch name, link the parent project first: supabase link --project-ref " + - legacyLinkTypoHint(value) - ); -} - const LEGACY_LINK_MAX_LISTED_BRANCHES = 20; function legacyLinkBranchNotFoundMessage( @@ -150,7 +133,7 @@ function legacyLinkBranchNotFoundMessage( branches: LegacyLinkBranches, ): string { if (branches.length === 0) { - return `Branch "${value}" not found: project ${parentRef} has no branches.${legacyLinkTypoHint(value)}`; + return `Branch "${value}" not found: project ${parentRef} has no branches.${legacyParentRefTypoHint(value)}`; } const sortedNames = branches.map((branch) => branch.name).toSorted(); @@ -173,7 +156,7 @@ function legacyLinkBranchNotFoundMessage( return ( `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}` + - `${didYouMean}${legacyLinkTypoHint(value)}` + `${didYouMean}${legacyParentRefTypoHint(value)}` ); } @@ -202,14 +185,12 @@ const resolveLegacyLinkBranchRef = Effect.fnUntraced(function* (value: string) { const parent = yield* legacyResolveLinkedParentRef(); if (parent.kind === "absent") { return yield* Effect.fail( - new LegacyLinkBranchNotLinkedError({ message: legacyLinkNotLinkedMessage(value) }), + new LegacyLinkBranchNotLinkedError({ message: legacyParentNotLinkedMessage(value) }), ); } if (parent.kind === "invalid") { return yield* Effect.fail( - new LegacyLinkParentRefInvalidError({ - message: `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref `, - }), + new LegacyLinkParentRefInvalidError({ message: legacyParentRefInvalidMessage(value) }), ); } const parentRef = parent.ref; diff --git a/apps/cli/src/legacy/commands/link/link.integration.test.ts b/apps/cli/src/legacy/commands/link/link.integration.test.ts index b077199df9..6e76ab76cc 100644 --- a/apps/cli/src/legacy/commands/link/link.integration.test.ts +++ b/apps/cli/src/legacy/commands/link/link.integration.test.ts @@ -9,9 +9,11 @@ import * as HttpClientRequestModule from "effect/unstable/http/HttpClientRequest import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockContextualAnalytics, + mockOutput, +} from "../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -299,53 +301,6 @@ function legacyTransportFailureForMock() { return legacyTransportFailure(HttpClientRequestModule.get("https://api.supabase.com/mock")); } -// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors the -// identical local helper in `functions/download/download.integration.test.ts` -// and `legacy-command-instrumentation.unit.test.ts`. The shared `mockAnalytics()` -// deliberately doesn't merge this context (most callers don't need it), but -// `legacyLink`'s own `withAnalyticsContext({ groups: { project: ref } })` call -// (CLI-2167 branch-link telemetry) needs it merged to assert on `groups`. -// Shape-compatible with `mockAnalytics()`'s return (adds `identified`/`aliased`/ -// `groupIdentified` tracking) so it's a drop-in `SetupOpts.analytics` override. -function mockContextualAnalytics(): ReturnType { - const captured: Array<{ event: string; properties: Record }> = []; - const identified: Array<{ distinctId: string; properties: Record }> = []; - const aliased: Array<{ distinctId: string; alias: string }> = []; - const groupIdentified: Array<{ - groupType: string; - groupKey: string; - properties: Record; - }> = []; - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ event, properties: { ...context, ...properties } }); - }), - identify: (distinctId: string, properties: Record = {}) => - Effect.sync(() => { - identified.push({ distinctId, properties }); - }), - alias: (distinctId: string, alias: string) => - Effect.sync(() => { - aliased.push({ distinctId, alias }); - }), - groupIdentify: ( - groupType: string, - groupKey: string, - properties: Record = {}, - ) => - Effect.sync(() => { - groupIdentified.push({ groupType, groupKey, properties }); - }), - }), - ); - return { layer, captured, identified, aliased, groupIdentified }; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/apps/cli/src/legacy/config/legacy-project-ref.service.ts b/apps/cli/src/legacy/config/legacy-project-ref.service.ts index 2468cc1202..efca37255a 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.service.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.service.ts @@ -1,6 +1,7 @@ import type { Effect, Option } from "effect"; import { Context } from "effect"; +import { LEGACY_BRANCH_PROJECT_REF_PATTERN } from "../shared/legacy-ref-patterns.ts"; import type { LegacyProjectRefReadError } from "../shared/legacy-temp-paths.ts"; import type { LegacyInvalidProjectRefError, @@ -86,7 +87,10 @@ export class LegacyProjectRefResolver extends Context.Service< LegacyProjectRefResolverShape >()("supabase/legacy/ProjectRefResolver") {} -export const PROJECT_REF_PATTERN = /^[a-z]{20}$/; +// `legacy-ref-patterns.ts` is the single canonical definition; re-exported under this +// module's established name since telemetry redaction (`safeFlags` gating) and several +// resolvers import it from here. +export const PROJECT_REF_PATTERN = LEGACY_BRANCH_PROJECT_REF_PATTERN; export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run supabase link?"; diff --git a/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts new file mode 100644 index 0000000000..5edc80e7e1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-branch-ref.resolver.ts @@ -0,0 +1,64 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; + +import { + LEGACY_BRANCH_PROJECT_REF_PATTERN, + LEGACY_BRANCH_UUID_PATTERN, +} from "./legacy-ref-patterns.ts"; + +/** + * Per-family error mapping for {@link legacyResolveBranchProjectRef}: each + * caller keeps its own tagged error classes (built with `mapLegacyHttpError`) + * so error identities, messages, and actionability stay family-owned. + */ +export interface LegacyBranchRefResolveMappers { + /** Maps a `GET /v1/branches/{branch_id}` (UUID lookup) failure. */ + readonly mapGetError: (cause: SupabaseApiError) => Effect.Effect; + /** Maps a `GET /v1/projects/{ref}/branches/{name}` (name lookup) failure. */ + readonly mapFindError: (cause: SupabaseApiError) => Effect.Effect; +} + +/** + * Resolves an arbitrary branch identifier to its project ref: + * + * 1. If the input matches `^[a-z]{20}$`, it's already a project ref — return as-is. + * 2. Else if the input is a UUID, call `V1GetABranchConfig` (`GET /v1/branches/{id}`) + * and return `JSON200.ref`. + * 3. Otherwise treat as a branch name under the linked project ref: call + * `V1GetABranch` (`GET /v1/projects/{ref}/branches/{name}`) and return + * `JSON200.project_ref`. + * + * The parent project ref is required only for path 3, so it may be passed + * lazily as an Effect — it is evaluated exactly then, never for a ref-shaped + * or UUID input. That keeps `--project-ref ` working in an unlinked + * directory: the UUID endpoint does not use a parent ref, so requiring one + * up front would fail invocations the API itself can serve. + */ +export function legacyResolveBranchProjectRef( + input: string, + projectRef: string | Effect.Effect, + mappers: LegacyBranchRefResolveMappers, +) { + return Effect.gen(function* () { + if (LEGACY_BRANCH_PROJECT_REF_PATTERN.test(input)) { + return input; + } + + const api = yield* LegacyPlatformApi; + + if (LEGACY_BRANCH_UUID_PATTERN.test(input)) { + const detail = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: input }) + .pipe(Effect.catch(mappers.mapGetError)); + return detail.ref; + } + + const parentRef = typeof projectRef === "string" ? projectRef : yield* projectRef; + const branch = yield* api.v1 + .getABranch({ ref: parentRef, name: input }) + .pipe(Effect.catch(mappers.mapFindError)); + return branch.project_ref; + }); +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index d60b9b60f9..0b6f12fd10 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -7,6 +7,7 @@ import { ErrorActionabilityFingerprintId, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import { LEGACY_BRANCH_PROJECT_REF_PATTERN } from "./legacy-ref-patterns.ts"; import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** @@ -106,11 +107,11 @@ import { legacyGoUrlParse } from "./legacy-storage-url.ts"; * this has zero effect on any real test. */ -// The project-ref pattern: exactly 20 lowercase -// ASCII letters. Exported from this module (was private in D before this relocation) as the -// canonical home; D's `findInvalidRemoteProjectId` is today the only consumer — the -// `remotes[*].project_id` check itself stays D-only forever, see the module header above. -export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; +// The project-ref pattern: exactly 20 lowercase ASCII letters. `legacy-ref-patterns.ts` is the +// single canonical definition; re-exported under this module's established name since D's +// `findInvalidRemoteProjectId` is today the only consumer — the `remotes[*].project_id` check +// itself stays D-only forever, see the module header above. +export const LEGACY_PROJECT_REF_PATTERN = LEGACY_BRANCH_PROJECT_REF_PATTERN; // The storage bucket-name pattern. // Validation runs this over every `[storage.buckets.*]` key diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index b856e50235..b6907b915d 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -48,13 +48,31 @@ function sanitizeErrorBody(input: string): string { // Strip ASCII control chars except \t (0x09), \n (0x0a), \r (0x0d) and DEL (0x7f). // Then also strip CR — we keep \n and \t because they appear in legitimate JSON // pretty-printing and shouldn't visually corrupt single-line stderr output. + // + // Also strips (benefits every caller of this function, and of + // `legacySanitizeInlineName`, which builds on it): + // - C1 controls (0x80-0x9F) — U+009B is CSI on some terminals, equivalent to + // the ESC+`[` sequence `sanitizeLegacyErrorBody`/friends are otherwise + // guarding against. + // - Bidi controls (U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) — can + // visually reorder or hide surrounding text in a terminal/log. + // - Unicode line separators (U+2028, U+2029) — fracture single-line output + // the same way `\r`/`\n` do, without being one of the two whitespace + // characters this function otherwise keeps for JSON readability. let out = ""; for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i); const isLowCtrl = code < 0x20 && code !== 0x09 && code !== 0x0a; const isDel = code === 0x7f; const isCr = code === 0x0d; - if (isLowCtrl || isDel || isCr) continue; + const isC1 = code >= 0x80 && code <= 0x9f; + const isBidiControl = + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069); + const isLineSeparator = code === 0x2028 || code === 0x2029; + if (isLowCtrl || isDel || isCr || isC1 || isBidiControl || isLineSeparator) continue; out += input[i]; } return out; diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts index fa92d83d8b..b888e62e22 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts @@ -4,7 +4,7 @@ import { Data, Effect } from "effect"; import * as HttpBody from "effect/unstable/http/HttpBody"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; -import { mapLegacyHttpError } from "./legacy-http-errors.ts"; +import { legacySanitizeInlineName, mapLegacyHttpError } from "./legacy-http-errors.ts"; class TestNetworkError extends Data.TaggedError("TestNetworkError")<{ readonly message: string; @@ -59,3 +59,43 @@ describe("mapLegacyHttpError", () => { }), ); }); + +describe("legacySanitizeInlineName", () => { + it("strips a right-to-left override so a path segment survives without the control char", () => { + // U+202E (RLO) can visually reorder everything after it in a terminal/log + // line — e.g. disguising a malicious path segment as something benign. + const rlo = String.fromCodePoint(0x202e); + const sanitized = legacySanitizeInlineName(`evil${rlo}name`); + expect(sanitized).not.toContain(rlo); + expect(sanitized).toBe("evilname"); + }); + + it("strips other bidi controls (LRM/RLM, embeddings, isolates)", () => { + const controls = [0x200e, 0x200f, 0x202a, 0x202b, 0x2066, 0x2069].map((code) => + String.fromCodePoint(code), + ); + const hostile = ["a", "b", "c", "d", "e", "f"] + .map((letter, index) => `${letter}${controls[index]}`) + .join(""); + const sanitized = legacySanitizeInlineName(hostile); + expect(sanitized).toBe("abcdef"); + }); + + it("strips C1 controls, including U+009B (CSI on some terminals)", () => { + // Equivalent to the ESC `[` sequence the ASCII control strip already + // guards against, just reachable via a single C1 code point. + const csi = String.fromCodePoint(0x9b); + const sanitized = legacySanitizeInlineName(`evil${csi}[31mname`); + expect(sanitized).not.toContain(csi); + expect(sanitized).toBe("evil[31mname"); + }); + + it("strips unicode line separators, which can fracture single-line output like \\n does", () => { + const lineSep = String.fromCodePoint(0x2028); + const paraSep = String.fromCodePoint(0x2029); + const sanitized = legacySanitizeInlineName(`first${lineSep}second${paraSep}third`); + expect(sanitized).not.toContain(lineSep); + expect(sanitized).not.toContain(paraSep); + expect(sanitized).toBe("firstsecondthird"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-parent-project-ref.ts b/apps/cli/src/legacy/shared/legacy-parent-project-ref.ts index 6e3e334b09..c7fa0e10c4 100644 --- a/apps/cli/src/legacy/shared/legacy-parent-project-ref.ts +++ b/apps/cli/src/legacy/shared/legacy-parent-project-ref.ts @@ -196,3 +196,41 @@ export const legacyResolveParentScopedProjectRef = Effect.fnUntraced(function* ( return yield* resolver.resolve(Option.none()); }); + +/** + * A value made entirely of lowercase letters (but not 20 of them, or it would + * already have been treated as a ref) is a plausible ref typo. Appended to + * both {@link legacyParentNotLinkedMessage} and `link`'s own + * branch-not-found message — shared by every command that resolves a branch + * name/UUID under the currently-linked PARENT project (CLI-2167). + */ +export function legacyParentRefTypoHint(value: string): string { + if (!/^[a-z]+$/.test(value)) return ""; + return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; +} + +/** + * Shared "no project is linked to search for branches" message, produced when + * {@link legacyResolveLinkedParentRef} reports `"absent"` for a non-ref-shaped + * `--project-ref`/positional value. Used by both `link` and `config diff` + * (Hoist Before You Duplicate — ≥2 commands across families). + */ +export function legacyParentNotLinkedMessage(value: string): string { + return ( + `Cannot resolve "${value}": it is not a project ref (refs are exactly 20 lowercase letters, ` + + "like `abcdefghijklmnopqrst`), so it was treated as a branch name — but no project is linked " + + "to search for branches.\n" + + " If it is a branch name, link the parent project first: supabase link --project-ref " + + legacyParentRefTypoHint(value) + ); +} + +/** + * Shared "the linked project ref is invalid" message, produced when + * {@link legacyResolveLinkedParentRef} reports `"invalid"` for a non-ref-shaped + * `--project-ref`/positional value. Used by both `link` and `config diff` + * (Hoist Before You Duplicate — ≥2 commands across families). + */ +export function legacyParentRefInvalidMessage(value: string): string { + return `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref `; +} diff --git a/apps/cli/src/legacy/shared/legacy-ref-patterns.ts b/apps/cli/src/legacy/shared/legacy-ref-patterns.ts new file mode 100644 index 0000000000..6c8cae80c1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-ref-patterns.ts @@ -0,0 +1,20 @@ +/** + * Service-free ref/branch identifier patterns, shared by the resolver + * machinery and pure formatters alike — a formatter documented as "no + * Effect, no services" must not pull a service-bearing module into its + * dependency graph just to reach a regex. + */ + +/** + * Project ref pattern shared by every Management-API endpoint that accepts a + * 20-lowercase-letter project reference. + */ +export const LEGACY_BRANCH_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +/** + * Permissive UUID pattern (any 8-4-4-4-12 hex sequence) — accepts any RFC 4122 + * variant including v6/v7 and version 0, matching the established liberal + * acceptance rather than the v1–v5 + variant-1 subset. + */ +export const LEGACY_BRANCH_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index 32494b43a9..fe384be406 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -449,9 +449,21 @@ function withLegacyCommandAnalyticsImplementation; - }> = []; - - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ - event, - properties: { - ...context, - ...properties, - }, - }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - - return { layer, captured }; -} - function failingAnalytics(defect: unknown) { return Layer.succeed( Analytics, @@ -874,6 +849,110 @@ describe("withLegacyCommandInstrumentation", () => { ); }); + it.live( + "records config diff --exit-code drift (exit 2) truthfully, without failure metadata", + () => { + // `config diff --exit-code` sets ProcessControl's exit code to 2 to signal + // drift WITHOUT failing the Effect (diff.handler.ts's own 0/1/2 convention: + // 2 means "drift found", not "command failed"). That is the command's own + // successful outcome, not a process-controlled failure — it must not + // collapse to 1 or attach db lint/db advisors-style failure classification. + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + + return Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(2); + }).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(processControl.layer), + Effect.provide(mockOutput({ format: "json" }).layer), + Effect.provide( + Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--exit-code"]) }), + ), + Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured).toHaveLength(1); + const event = analytics.captured[0]; + expect(event?.properties.exit_code).toBe(2); + for (const property of FAILURE_PROPERTY_NAMES) { + expect(event?.properties).not.toHaveProperty(property); + } + }), + ), + ); + }, + ); + + it.live( + "still collapses another command's process-controlled exit 2 to 1 (unknown classification)", + () => { + // Only `config diff` gets the drift-signal exception above — any other + // command setting exit code 2 without failing the Effect keeps today's + // behavior: collapse to 1 and fall back to the unknown classification. + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + + return Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(2); + }).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(processControl.layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), + Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "unknown", + error_category: "unknown", + error_fingerprint: "error:ProcessControlledFailure", + has_suggestion: false, + suggestion_type: "none", + }); + }), + ), + ); + }, + ); + + it.live("classifies a config diff load failure normally (exit 1, cause-derived metadata)", () => { + // A real command failure on `config diff` (e.g. an unparseable config + // file) must not be swept into the drift-signal exception above — it + // still fails the Effect, so recordedExitCode stays 1 with the usual + // cause-derived classification. + const analytics = mockContextualAnalytics(); + const failure = new LegacyConfigDiffLoadConfigError({ message: "invalid config" }); + + return Effect.fail(failure).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["config", "diff"]) })), + Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.exit, + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyConfigDiffLoadConfigError", + has_suggestion: true, + suggestion_type: "update_config", + }); + }), + ), + Effect.asVoid, + ); + }); + it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index c1586e5f7b..1d5c1a9fc6 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -26,16 +26,24 @@ import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./leg // Go's own text byte-for-byte; before that, this only reached the TS-native // `--help` renderer, whose overall layout already diverges from cobra's, so // the mismatch was harder to notice (CLI-1965 review finding). +/** + * Every value the global `-o/--output` flag accepts — the single source of + * truth for per-command `outputFormats` overrides that widen the wrapper's + * enum check to "all values" (e.g. `config diff`'s handler-level rejection), + * so a value added here automatically flows to those commands and their tests. + */ +export const LEGACY_GLOBAL_OUTPUT_FORMATS = [ + "env", + "pretty", + "json", + "toml", + "yaml", + "table", + "csv", +] as const; + export const LegacyOutputFlag = GlobalFlag.setting("output")({ - flag: Flag.choice("output", [ - "env", - "pretty", - "json", - "toml", - "yaml", - "table", - "csv", - ] as const).pipe( + flag: Flag.choice("output", LEGACY_GLOBAL_OUTPUT_FORMATS).pipe( Flag.withAlias("o"), Flag.withDescription("output format of status variables"), Flag.optional, diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 8c1b695138..d5a45fb8bc 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -1381,7 +1381,7 @@ export function mockLegacyDockerDaemonCliSpawner( // Layer.mergeAll" — centralising the subgraph here removes a recurring footgun). // --------------------------------------------------------------------------- -type GoOutputValue = "env" | "pretty" | "json" | "toml" | "yaml"; +type GoOutputValue = "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; // --------------------------------------------------------------------------- // Analytics mock lives in `./mocks.ts` (`mockAnalytics`) — same shape we used diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 3037c551eb..0fd0fe696d 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -37,6 +37,7 @@ import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; import { Stdin } from "../../src/shared/runtime/stdin.service.ts"; import { Tty } from "../../src/shared/runtime/tty.service.ts"; import { Analytics } from "../../src/shared/telemetry/analytics.service.ts"; +import { CurrentAnalyticsContext } from "../../src/shared/telemetry/analytics-context.ts"; import { TelemetryRuntime } from "../../src/shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../src/shared/telemetry/identity.ts"; @@ -519,6 +520,53 @@ export function mockApi( }; } +/** + * `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through + * `CurrentAnalyticsContext`, not the direct `capture()` call args. The plain + * `mockAnalytics()` below deliberately doesn't merge that context (most + * callers don't need it); tests asserting on context-carried properties + * (telemetry `flags` maps, `groups`) use this variant instead. + * Shape-compatible with `mockAnalytics()`'s return so it's a drop-in + * override wherever a setup helper accepts one. + */ +export function mockContextualAnalytics(): ReturnType { + const captured: Array<{ event: string; properties: Record }> = []; + const identified: Array<{ distinctId: string; properties: Record }> = []; + const aliased: Array<{ distinctId: string; alias: string }> = []; + const groupIdentified: Array<{ + groupType: string; + groupKey: string; + properties: Record; + }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: (distinctId: string, properties: Record = {}) => + Effect.sync(() => { + identified.push({ distinctId, properties }); + }), + alias: (distinctId: string, alias: string) => + Effect.sync(() => { + aliased.push({ distinctId, alias }); + }), + groupIdentify: ( + groupType: string, + groupKey: string, + properties: Record = {}, + ) => + Effect.sync(() => { + groupIdentified.push({ groupType, groupKey, properties }); + }), + }), + ); + return { layer, captured, identified, aliased, groupIdentified }; +} + export function mockAnalytics() { const captured: Array<{ event: string; diff --git a/docs/adr/0022-config-diff-classification-and-managed-surface.md b/docs/adr/0022-config-diff-classification-and-managed-surface.md new file mode 100644 index 0000000000..3af2ee07c1 --- /dev/null +++ b/docs/adr/0022-config-diff-classification-and-managed-surface.md @@ -0,0 +1,53 @@ +# 0022. Config Diff Classification and Managed Surface + +**Status**: accepted +**Date**: 2026-08-20 (registry consolidation 2026-08-28; review revision 2026-08-31) + +## Problem Statement + +`supabase config diff` (CLI-2156) compares the local `config.toml` against the effective configuration `GET /v2/projects/{ref}/config` reports, and `config pull` (CLI-2064) will delegate to the same engine. Three classification problems make a naive walk wrong: + +1. **Key-set asymmetry.** The earlier POC walked only keys present in the remote response, so a property the file declares and the remote doesn't return was structurally invisible. The inverse walk (local keys only) would hide remote-side drift the file never mentions. +2. **Managed vs. unmanaged.** Most of `config.toml` configures the _local_ stack — `[studio]`, ports, image pins, `[db.migrations]` — and has no platform counterpart. Reporting those as drift is noise; deciding which properties the platform manages needs a source of truth that cannot drift from the code that reads the response. +3. **Incomparable values.** The platform masks secrets (HMAC, never plaintext), reports byte counts where the file writes `"50MiB"`, comma-joins arrays, and types some scalars differently than the schema. Comparing representations instead of meanings misreports drift; silently skipping them misreports cleanliness. + +This ADR was first accepted with a self-contained translation table inside `config-diff.ts` (a `read`-function-per-managed-path port of the Go CLI's `FromRemoteAuthConfig` at `7b469f5b3`). CLI-2230 (PR supabase/cli#6339) then landed the registry-driven `ProjectConfig` convergence normalizers — the same translation, shared with Studio, governed by ADR 0019 (passthrough), ADR 0020 (naming), and ADR 0021 (convergence semantics). Keeping two translations would have been exactly the parallel code path the repo's refactoring policy forbids, so the classifier now consumes the registry; this revision records the consolidated design. + +## Decision + +`@supabase/config` owns the comparison core as pure, synchronous functions (`config-diff.ts`), with no dependency on `@supabase/api`, output formatting, or command flags — layered on CLI-2230's registry rather than a translation of its own. It is CLI-only today: `diffProjectConfig` is exported solely from the `internal` subpath, which `monorepo-import-contract.unit.test.ts` restricts to `apps/cli` — Studio has no path to it. Promotion to the public entrypoint is deliberately deferred to a follow-up when a second consumer (Studio, or a push mapper) actually needs it, rather than exported speculatively: + +- **Both operands are `ProjectConfig` convergence projections (ADR 0021).** The caller builds the local operand with `fromConfigDocument({config, document})` — raw-presence-masked, canonicalized, secret-omitting — and the remote operand with `fromApiProjectConfig(response)`. All wire-shape knowledge (renames, inversions, unit conversions, the GoTrue key table) lives in `projectConfigMappingRows`, once, shared with Studio and the future push mapper. +- **The managed surface is the registry's.** The classifier walks the union of both operands' leaf paths filtered by `isComparableProjectConfigPath` — a path with no registry row is _unmanaged by construction_ and never reported (`[studio]`, ports, image pins, `[realtime]` locals, `workers`). +- **Three-way classification per comparable path**, driven by _declared_ presence (the raw pre-decode document), which a decoded config cannot recover: `update` (declared + reported, values differ), `remote_only` (reported while undeclared — or while push cannot communicate the declared state — and differing from the suppression baseline), `local_only` (a declared local projection value the response did not account for: parsed-but-never-pushed attributes and permission-truncated responses). The local operand follows ADR 0018: the branch's merged effective config when the target ref matches a `[remotes.*]` block's `project_id`, the base config otherwise. +- **`remote_only` suppression baseline**: the default config's own convergence projection, falling back — for push-gated containers the projection is silent on — to the raw default config's value (`db.network_restrictions`' allow-all default IS the platform's unconfigured state), then to the registry row's declared `unconfiguredValue` (the platform's own report of an unconfigured feature — GoTrue's `sessions_timebox: 0` canonicalizes to the STRING `"0s"`, pinned by the recorded `config_auth` fixtures). Platform-RENDERED strings (mailer template/notification subjects) get no pinned baseline at all: the platform authors those values and rewords them across environments, so a pinned copy cannot be simultaneously correct for prod and staging — their rows declare `platformRendered` instead, and any remote value is suppressed while the local projection is silent (a locally declared subject still classifies normally). "Unconfigured" is never inferred from type-level zero values — canonicalization can turn a platform zero into a non-zero shape, and PR #6295's review reproduced 15 noise lines on an untouched staging project from exactly that inference. A path with no baseline at any tier reports rather than guesses (over-report over under-report). An unconfigured project therefore diffs clean instead of flooding with platform-default noise. +- **Equality is meaning-based**: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (`arrayEquality`), defaulting to sequence — `api.schemas`' first entry is PostgREST's default schema and `api.extra_search_path` is a literal `search_path`, so reordering is drift; `auth.additional_redirect_urls` opts into set semantics. +- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops (ADR 0021's unmanaged-by-push families — `auth.oauth_server`, disabled `storage.analytics`/`vector`, sentinel-pruned siblings) can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it. +- **Change entries carry what a consumer needs verbatim**: `path` is a SEGMENT ARRAY end to end (an `sms.test_otp` phone-number key may itself contain a `.`; joining is display-only), `declared` distinguishes file-written values from schema-materialized defaults, and a `remote_only` entry keeps the materialized local default so "what would `config push` change?" is answerable from the change alone — the primary dashboard-drift use case, and what `config pull` will need to write back. +- **Command surface (review revision, PR #6295)**: the target flag is `link`'s settled vocabulary — a single `--project-ref` accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`. The global `-o/--output` flag is rejected outright — every value, `pretty` included — with a message pointing at `--output-format` (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`). +- The interpolation pipeline records the resolving env var name on `"environment"` value origins, so a change on an `env()`-fed property can name the variable involved. + +The command layer (`apps/cli/src/legacy/commands/config/diff/`) only resolves the target, fetches, projects, and renders. Per ADR 0021's rendering rule, reported "local" values are the convergence projection — what pushing the file would produce hosted — not the file's literal spelling. + +## Considered Alternatives + +1. **Derive the managed set from the response keys** (the POC's approach): whatever the remote returns is what's compared. Structurally blind to `local_only`, and a permission-truncated response silently shrinks the comparison. +2. **Schema annotations (`x-managed`) on each property**: keeps the knowledge in the schema, but the annotation and the response-reading code can disagree, and the annotation cannot express per-property wire transforms that the registry rows carry. +3. **The original self-contained translation table** (this ADR's first accepted form): correct, but once CLI-2230 landed the registry it became a ~600-line parallel implementation of the same mapping with independently-drifting transforms. Superseded by the consolidation above. +4. **Reuse `config push`'s `config-sync` diffing** (`apps/cli/src/legacy/commands/config/push/config-sync/`): those helpers produce per-service unified-diff _text_ against the v1 per-service endpoints for push previews, not a typed change set, and they live in the CLI app. They remain the Go-parity push path; the registry rows were themselves mined from them (CLI-2230), and a shared push mapper is that ticket's tracked follow-up. + +## Consequences + +- `config pull` gets its comparison engine for free: the change set is typed data, and `fromApiProjectConfig` already produces the local representation of any remote value it needs to write. +- Adding a newly platform-managed property is one registry row (shared with Studio); forgetting it means the property is silently unmanaged (never misreported as drift), which fails safe. +- The wire shape is pinned by `apps/cli`'s `project-config-api-drift.unit.test.ts` type-drift guard plus the registry's lenient decode (ADR 0019); API evolution degrades to "not reported" rather than crashing, and the live test is the tripwire. +- Platform defaults that diverge from schema defaults surface as `remote_only` drift by design — the file's meaning is defined by the schema defaults reference (ADR 0018), not by what the platform would have picked. +- The classifier inherits ADR 0021's limits verbatim: ADR 0021's "honest-but-push-unactionable" residual category (unconditionally-mapped fields with no local-silence signal, tracked on CLI-2266) surfaces here as `remote_only` entries a user cannot fix by editing their file. + +## Related Decisions + +- [ADR 0018](0018-sparse-config-subtraction.md): Sparse Config Subtraction — the defaults baseline and merged-remote-block local operand this classification builds on. +- [ADR 0019](0019-config-api-response-passthrough.md): Raw API-Response Passthrough — the leniency boundary and `_apiResponse` escape hatch of the remote operand. +- [ADR 0020](0020-config-naming-vocabulary.md): Config Naming Vocabulary — `CliConfig` vs `ProjectConfig`. +- [ADR 0021](0021-projectconfig-convergence-semantics.md): ProjectConfig Convergence Semantics — what the two operand-producing normalizers compute, and why comparing them structurally is meaningful. +- [ADR 0006](0006-environment-management.md): Environment Management — remote blocks and branch mapping semantics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f38bc15fd..acb10ea67e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -62,6 +62,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0019 | [Raw API-Response Passthrough on API-Sourced Config](0019-config-api-response-passthrough.md) | accepted | | 0020 | [Config Naming Vocabulary](0020-config-naming-vocabulary.md) | accepted | | 0021 | [ProjectConfig Convergence Semantics](0021-projectconfig-convergence-semantics.md) | accepted | +| 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | ## Template diff --git a/packages/config/src/config-diff.ts b/packages/config/src/config-diff.ts new file mode 100644 index 0000000000..9d4fa58561 --- /dev/null +++ b/packages/config/src/config-diff.ts @@ -0,0 +1,479 @@ +import type { CliConfigValueOrigin } from "./config-document.ts"; +import { + type CliConfigWithRawPresence, + comparableProjectConfigPaths, + fromConfigDocument, + isComparableProjectConfigPath, + type ProjectConfig, +} from "./project-config/project-config.ts"; +import { projectConfigMappingRows } from "./project-config/registry.ts"; +import { getDefaultCliConfig } from "./sparse.ts"; + +/** + * Config drift classification between the local project config and the + * effective remote configuration reported by the Management API + * (`GET /v2/projects/{ref}/config`). Pure and synchronous: fetching the + * response, resolving the target, and rendering output are the caller's job + * (`supabase config diff`, and `config pull` after it). See ADR 0022. + * + * Both operands are convergence projections from CLI-2230's normalizers (ADR + * 0021): the local operand is derived here from the loaded `{config, + * document}` pair via `fromConfigDocument` (raw-presence-masked, + * canonicalized, secrets omitted), and the caller builds `remote` with + * `fromApiProjectConfig(response)`. The comparable surface is the mapping + * registry's — a path with no registry row is unmanaged by construction — + * and the raw document's declared-key set drives `update` vs `remote_only`, + * since a decoded config cannot distinguish "the file wrote the default" + * from "the file is silent". + * + * Paths are segment arrays everywhere in this module's API (a record key — + * an `auth.sms.test_otp` phone number, a `[remotes.*]` name — may itself + * contain a `.`, so dotted strings are lossy); joining is display-only and + * belongs to the renderer. + */ + +export type ConfigChangeClass = "update" | "remote_only" | "local_only"; + +export interface ConfigChange { + /** + * Config path segments within the hosted subset, e.g. `["api", + * "max_rows"]`. Join for display only — a segment may contain a `.`. + */ + readonly path: ReadonlyArray; + /** + * `update`: declared locally and reported remotely, values differ. + * `remote_only`: reported remotely while the file does not declare it (or + * push cannot communicate the declared state), and differing from the + * unconfigured baseline. `local_only`: the local projection carries a + * declared value the response did not account for. + */ + readonly class: ConfigChangeClass; + /** + * Local convergence-projected value; `undefined` when the projection is + * silent. For an undeclared `remote_only` path this is the materialized + * schema default — the value a `config push` would write over the remote — + * so consumers can answer "what would push change?" without re-deriving it. + */ + readonly local: unknown; + /** Remote value; `undefined` when the response did not report it. */ + readonly remote: unknown; + /** + * Whether the raw document declares this path — distinguishes "the file + * wrote this value" from "the local side is a schema-materialized default". + */ + readonly declared: boolean; + /** Environment variables local `env()` references resolved from, if any. */ + readonly envVariables?: ReadonlyArray | undefined; +} + +export interface ConfigChangeCounts { + readonly update: number; + readonly remote_only: number; + readonly local_only: number; + readonly total: number; +} + +export interface ConfigChangeSet { + /** Reportable differences, ordered by path. */ + readonly changes: ReadonlyArray; + /** + * Managed secret paths the file sets a value for (the registry's + * `isSecret` rows). These were never compared — the platform reports HMAC + * digests, and both normalizers omit secret leaves — so a clean `changes` + * list is still only a partial claim; callers must surface this. + */ + readonly masked: ReadonlyArray>; + /** + * Comparable non-secret paths the file declares but the local projection + * dropped — declared state a `config push` structurally cannot communicate + * (ADR 0021's unmanaged-by-push families: `auth.oauth_server`, disabled + * `storage.analytics`/`storage.vector`, siblings of a disabled container's + * sentinel, an unselected SMS provider's credentials, …). These were never + * compared on the local side, so — like `masked` — a clean `changes` list + * is only a partial claim; callers must surface this rather than let a + * declared value silently vanish from the comparison. + */ + readonly unmanaged: ReadonlyArray>; + readonly counts: ConfigChangeCounts; +} + +export interface DiffProjectConfigOptions { + /** + * The loaded local config: the `{config, document}` pair + * `fromConfigDocument` accepts (pass the loaded config WITH its raw + * document so raw-presence masking applies — ADR 0021's remedy), plus the + * loader's `valueOrigins` when env-var attribution is wanted. The local + * projection and the declared-key set are both derived from this one value, + * so they can never come from different loads. `LoadedCliConfig` is + * structurally assignable. Note `fromConfigDocument` runs inside + * `diffProjectConfig`, so a document the registry cannot canonicalize + * throws `ProjectConfigParseError` from here. + */ + readonly local: CliConfigWithRawPresence & { + readonly valueOrigins?: ReadonlyArray | undefined; + }; + /** The remote operand: `fromApiProjectConfig(response)`. */ + readonly remote: ProjectConfig; +} + +function isPlainRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pathKey(path: ReadonlyArray): string { + return JSON.stringify(path); +} + +/** Walks a segment path through records with own-key checks only. */ +function valueAtPath(root: unknown, path: ReadonlyArray): unknown { + let current: unknown = root; + for (const segment of path) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function isDeclaredAtPath( + root: Readonly>, + path: ReadonlyArray, +): boolean { + let current: unknown = root; + for (const [index, segment] of path.entries()) { + if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { + return false; + } + if (index < path.length - 1) { + current = current[segment]; + } + } + return true; +} + +/** Collects leaf paths as segment arrays (arrays are leaves; records recurse). */ +function collectLeafPaths(root: ProjectConfig): Array> { + const leaves: Array> = []; + const walk = (value: unknown, prefix: ReadonlyArray): void => { + if (isPlainRecord(value)) { + for (const [key, child] of Object.entries(value)) { + walk(child, [...prefix, key]); + } + return; + } + if (prefix.length > 0) { + leaves.push(prefix); + } + }; + walk(root, []); + return leaves; +} + +/** Segment-wise path order — the display order of the change list. */ +function comparePaths(a: ReadonlyArray, b: ReadonlyArray): number { + const length = Math.min(a.length, b.length); + for (let index = 0; index < length; index++) { + const left = a[index] as string; + const right = b[index] as string; + if (left !== right) { + return left < right ? -1 : 1; + } + } + return a.length - b.length; +} + +function scalarEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + // Type-aware comparison: both operands are already canonicalized by the + // convergence normalizers, but representation skew across schema versions + // ("8080" vs 8080, "true" vs true) is still not drift. + if (typeof a === "string" && typeof b === "number") { + const parsed = Number(a.trim()); + return a.trim() !== "" && Number.isFinite(parsed) && parsed === b; + } + if (typeof a === "number" && typeof b === "string") { + return scalarEqual(b, a); + } + if (typeof a === "string" && typeof b === "boolean") { + return a.trim().toLowerCase() === String(b); + } + if (typeof a === "boolean" && typeof b === "string") { + return scalarEqual(b, a); + } + return false; +} + +function canonicalArrayElement(value: unknown): string { + if (typeof value === "string") { + return `s:${value}`; + } + if (typeof value === "number" || typeof value === "boolean") { + // Scalars fold to their string form so "1" and 1 compare equal, matching + // the scalar type-awareness above. + return `s:${String(value)}`; + } + return `j:${JSON.stringify(value)}`; +} + +export type ConfigArrayEquality = "set" | "sequence"; + +/** + * Type-aware value equality. Scalars tolerate string/number and + * string/boolean representation skew. Arrays default to SEQUENCE semantics — + * element order is meaningful unless the field's registry row opts into + * `"set"` (whether an array is a set or a sequence is per-field wire + * knowledge: `api.schemas`' first entry is PostgREST's default schema and + * `api.extra_search_path` is a literal `search_path`, while + * `auth.additional_redirect_urls` is membership-only). Defaulting to + * sequence over-reports rather than under-reports drift. + */ +export function isEqualConfigValue( + a: unknown, + b: unknown, + arrayEquality: ConfigArrayEquality = "sequence", +): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + if (arrayEquality === "set") { + // TRUE set semantics: membership only. Duplicates carry no meaning for + // a set-mode field (a repeated redirect URL is the same allow list), + // so they must not register as drift. + const left = new Set(a.map(canonicalArrayElement)); + const right = new Set(b.map(canonicalArrayElement)); + return left.size === right.size && [...left].every((element) => right.has(element)); + } + if (a.length !== b.length) { + return false; + } + const left = a.map(canonicalArrayElement); + const right = b.map(canonicalArrayElement); + return left.every((element, index) => element === right[index]); + } + return scalarEqual(a, b); +} + +/** Deduped secret (`isSecret`) row config paths, in registry order. */ +const secretConfigPaths: ReadonlyArray> = (() => { + const seen = new Set(); + const paths: Array> = []; + for (const row of projectConfigMappingRows) { + if (row.isSecret !== true || seen.has(pathKey(row.configPath))) { + continue; + } + seen.add(pathKey(row.configPath)); + paths.push(row.configPath); + } + return paths; +})(); + +// Per-path row knowledge the classifier consumes, first row wins — matching +// `comparableProjectConfigPaths`'s own dedupe order for paths several rows +// share. +const arrayEqualityByPathKey: ReadonlyMap = (() => { + const map = new Map(); + for (const row of projectConfigMappingRows) { + if (row.arrayEquality !== undefined && !map.has(pathKey(row.configPath))) { + map.set(pathKey(row.configPath), row.arrayEquality); + } + } + return map; +})(); + +const unconfiguredValueByPathKey: ReadonlyMap = (() => { + const map = new Map(); + for (const row of projectConfigMappingRows) { + if (row.unconfiguredValue !== undefined && !map.has(pathKey(row.configPath))) { + map.set(pathKey(row.configPath), row.unconfiguredValue); + } + } + return map; +})(); + +/** + * Paths whose row is `platformRendered` (`./project-config/registry-row.ts`): + * the platform authors the value itself, so a local-silent report at that + * path suppresses unconditionally rather than matching a pinned baseline. + */ +const platformRenderedPathKeys: ReadonlySet = (() => { + const keys = new Set(); + for (const row of projectConfigMappingRows) { + if (row.platformRendered === true) { + keys.add(pathKey(row.configPath)); + } + } + return keys; +})(); + +/** + * Equality at a specific path: array semantics come from the path's registry + * row (or the nearest mapped ancestor — a mapped container's descendant + * leaves inherit its row), defaulting to sequence. + */ +function equalsAtPath(path: ReadonlyArray, a: unknown, b: unknown): boolean { + for (let length = path.length; length >= 1; length--) { + const equality = arrayEqualityByPathKey.get(pathKey(path.slice(0, length))); + if (equality !== undefined) { + return isEqualConfigValue(a, b, equality); + } + } + return isEqualConfigValue(a, b); +} + +/** + * Nearest-ancestor prefix membership: true when `path` or any ancestor + * prefix is a key in `set` — mirrors {@link equalsAtPath}'s own ancestor walk + * so a mapped container's descendant leaves inherit its membership too. + */ +function hasAncestorPathKey(path: ReadonlyArray, set: ReadonlySet): boolean { + for (let length = path.length; length >= 1; length--) { + if (set.has(pathKey(path.slice(0, length)))) { + return true; + } + } + return false; +} + +// The default config's own convergence projection — the first `remote_only` +// suppression baseline tier. Lazy so importing this module never pays for a +// full schema decode + projection up front. +let defaultProjectionMemo: ProjectConfig | undefined; +function defaultProjection(): ProjectConfig { + defaultProjectionMemo ??= fromConfigDocument(getDefaultCliConfig()); + return defaultProjectionMemo; +} + +/** + * Classifies every comparable path into the change set. Pure: no I/O, no + * dependency on command flags or output formatting. Runs `fromConfigDocument` + * over `options.local`, so a document the registry cannot canonicalize throws + * `ProjectConfigParseError` — callers translate at their own boundary. + */ +export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChangeSet { + const local = fromConfigDocument(options.local); + const declaredRoot = options.local.document ?? {}; + const envReferences = new Map>(); + for (const origin of options.local.valueOrigins ?? []) { + if (origin.source === "environment" && origin.envVariables !== undefined) { + envReferences.set(pathKey(origin.path), origin.envVariables); + } + } + + const changes: Array = []; + const paths = new Map>(); + for (const path of [...collectLeafPaths(local), ...collectLeafPaths(options.remote)]) { + paths.set(pathKey(path), path); + } + + // Declared comparable paths the local projection dropped: push cannot + // communicate them, so they were never compared on the local side. + // `comparableProjectConfigPaths` already excludes secret rows, so this + // never overlaps `masked`. Hoisted above the classification loop and + // excluded there before any classification branch runs — ADR 0022: an + // unmanaged path can never classify either, so it must never reach a + // branch that would compare it against the remote report. + const unmanaged = comparableProjectConfigPaths + .filter( + (path) => isDeclaredAtPath(declaredRoot, path) && valueAtPath(local, path) === undefined, + ) + .toSorted(comparePaths); + const unmanagedPathKeys = new Set(unmanaged.map(pathKey)); + + for (const path of paths.values()) { + if (!isComparableProjectConfigPath(path)) { + continue; + } + if (hasAncestorPathKey(path, unmanagedPathKeys)) { + continue; + } + const localValue = valueAtPath(local, path); + const remoteValue = valueAtPath(options.remote, path); + const declared = isDeclaredAtPath(declaredRoot, path); + const envVariables = envReferences.get(pathKey(path)); + + if (localValue !== undefined && remoteValue !== undefined) { + if (equalsAtPath(path, localValue, remoteValue)) { + continue; + } + // A declared value differing from the remote is an update; an + // undeclared one is remote-side drift against the (materialized) + // default the local projection carries — which stays populated on the + // change so consumers can see what a push would write. + changes.push({ + path, + class: declared ? "update" : "remote_only", + local: localValue, + remote: remoteValue, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + continue; + } + + if (remoteValue !== undefined) { + // The local projection is silent because the file doesn't declare this + // path — a declared-but-unmanaged path never reaches this branch, since + // the loop above already excluded it (ADR 0022). Suppress the remote + // value unconditionally when the row is `platformRendered` (the + // platform authors this value itself; no local default exists to + // compare against), or when it matches the unconfigured baseline: the + // default config's own projection, then the raw default config + // (push-gated containers, e.g. network restrictions' allow-all), then + // the registry row's declared `unconfiguredValue` (the platform's + // report of an unconfigured feature, e.g. `sessions_timebox: 0` + // canonicalized to `"0s"`). With no baseline at any tier the value is + // reported — "unconfigured" is never inferred from type-level zero + // values, since canonicalization can turn a platform zero into a + // non-zero shape. + const baseline = + valueAtPath(defaultProjection(), path) ?? + valueAtPath(getDefaultCliConfig(), path) ?? + unconfiguredValueByPathKey.get(pathKey(path)); + const suppressed = + // Exact match, not the unmanaged exclusion's ancestor walk: every + // platformRendered row is a leaf path. + platformRenderedPathKeys.has(pathKey(path)) || + (baseline !== undefined && equalsAtPath(path, baseline, remoteValue)); + if (!suppressed) { + changes.push({ + path, + class: "remote_only", + local: undefined, + remote: remoteValue, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + } + continue; + } + + if (declared) { + changes.push({ + path, + class: "local_only", + local: localValue, + remote: undefined, + declared, + ...(envVariables === undefined ? {} : { envVariables }), + }); + } + } + + changes.sort((a, b) => comparePaths(a.path, b.path)); + + const masked = secretConfigPaths + .filter((path) => isDeclaredAtPath(declaredRoot, path)) + .toSorted(comparePaths); + + const update = changes.filter((change) => change.class === "update").length; + const remote_only = changes.filter((change) => change.class === "remote_only").length; + const local_only = changes.filter((change) => change.class === "local_only").length; + + return { + changes, + masked, + unmanaged, + counts: { update, remote_only, local_only, total: update + remote_only + local_only }, + }; +} diff --git a/packages/config/src/config-diff.unit.test.ts b/packages/config/src/config-diff.unit.test.ts new file mode 100644 index 0000000000..e933bdc1c5 --- /dev/null +++ b/packages/config/src/config-diff.unit.test.ts @@ -0,0 +1,549 @@ +import { describe, expect, test } from "vitest"; +import { Schema } from "effect"; +import { CliConfigSchema } from "./base.ts"; +import { diffProjectConfig, isEqualConfigValue, type ConfigChange } from "./config-diff.ts"; +import type { CliConfigValueOrigin } from "./config-document.ts"; +import { + comparableProjectConfigPaths, + fromApiProjectConfig, + fromConfigDocument, +} from "./project-config/project-config.ts"; +import { projectConfigMappingRows } from "./project-config/registry.ts"; +import { getDefaultCliConfig } from "./sparse.ts"; + +const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); + +/** + * Builds the diff input the way the command layer does: the local operand is + * the loaded `{config, document}` pair (so raw-presence masking applies and + * the declared-key set comes from the same load), the remote operand is + * `fromApiProjectConfig` over bare v2 `data.attributes`. + */ +function diffWith( + declared: Record, + attributes: Record, + valueOrigins?: ReadonlyArray, +) { + return diffProjectConfig({ + local: { config: decodeCliConfig(declared), document: declared, valueOrigins }, + remote: fromApiProjectConfig(attributes), + }); +} + +function changeAt( + changes: ReadonlyArray, + path: ReadonlyArray, +): ConfigChange | undefined { + return changes.find( + (change) => + change.path.length === path.length && + change.path.every((segment, index) => segment === path[index]), + ); +} + +describe("diffProjectConfig classification", () => { + test("an undefined declared document means nothing is declared", () => { + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}) }, + remote: fromApiProjectConfig({ api: { max_rows: 250 } }), + }); + expect(changeAt(result.changes, ["api", "max_rows"])).toMatchObject({ class: "remote_only" }); + }); + + test("declared value differing from remote is an update", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 1000 } }); + const change = changeAt(result.changes, ["api", "max_rows"]); + expect(change).toMatchObject({ class: "update", local: 500, remote: 1000, declared: true }); + expect(result.counts.update).toBe(1); + }); + + test("declared value equal to remote is not a difference", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 500 } }); + expect(result.changes).toEqual([]); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0, total: 0 }); + }); + + test("remote value at the schema default is suppressed when undeclared", () => { + const result = diffWith({}, { api: { max_rows: 1000 } }); + expect(changeAt(result.changes, ["api", "max_rows"])).toBeUndefined(); + }); + + test("remote-only drift keeps the materialized local default and declared: false", () => { + // The primary someone-changed-it-in-the-dashboard case: the file is + // silent, the local projection carries the schema default (1000), and a + // push would overwrite the remote 250 with it — the change must say so. + const result = diffWith({}, { api: { max_rows: 250 } }); + const change = changeAt(result.changes, ["api", "max_rows"]); + expect(change).toMatchObject({ + class: "remote_only", + local: 1000, + remote: 250, + declared: false, + }); + }); + + test("raw-presence-masked sections suppress zero-valued remotes", () => { + // db.ssl_enforcement is raw-presence-masked on the document arm (ADR + // 0021), so its local projection is silent when the file never declares + // it; the platform reporting the unconfigured state is not drift. + const clean = diffWith({}, { database: { ssl_enforced: false } }); + expect(changeAt(clean.changes, ["db", "ssl_enforcement", "enabled"])).toBeUndefined(); + + const drifted = diffWith({}, { database: { ssl_enforced: true } }); + expect(changeAt(drifted.changes, ["db", "ssl_enforcement", "enabled"])).toMatchObject({ + class: "remote_only", + remote: true, + }); + }); + + test("push-gated containers fall back to the raw schema default as baseline", () => { + // The registry maps network-restriction CIDRs unconditionally, but push + // gates them on the local `enabled` toggle, so the default projection is + // silent on them. The raw schema default (allow-all) IS the platform's + // unconfigured state — reporting it would flag every untouched project. + const clean = diffWith( + {}, + { + database: { + network_restrictions: { + allowed_cidrs: [ + { address: "0.0.0.0/0", type: "v4" }, + { address: "::/0", type: "v6" }, + ], + }, + }, + }, + ); + expect(clean.changes).toEqual([]); + + const drifted = diffWith( + {}, + { + database: { + network_restrictions: { allowed_cidrs: [{ address: "10.0.0.0/8", type: "v4" }] }, + }, + }, + ); + expect( + changeAt(drifted.changes, ["db", "network_restrictions", "allowed_cidrs"]), + ).toMatchObject({ + class: "remote_only", + remote: ["10.0.0.0/8"], + }); + }); + + test("canonicalized zero durations suppress via the row's unconfiguredValue", () => { + // GoTrue reports 0 hours for unconfigured session bounds; the transform + // canonicalizes that to the STRING "0s", which no type-level zero check + // recognizes — the registry row's `unconfiguredValue` must. An untouched + // project reporting both bounds is clean; a real timebox is drift. + const clean = diffWith({}, { auth: { sessions_timebox: 0, sessions_inactivity_timeout: 0 } }); + expect(clean.changes).toEqual([]); + + const drifted = diffWith({}, { auth: { sessions_timebox: 24 } }); + expect(changeAt(drifted.changes, ["auth", "sessions", "timebox"])).toMatchObject({ + class: "remote_only", + remote: "24h0m0s", + }); + }); + + test("platform-rendered mailer subjects suppress regardless of the remote string", () => { + // Subject lines are rendered by the platform, so there is no fixed + // baseline string to pin — the row's `platformRendered` flag suppresses + // ANY remote string while the file stays silent, unlike + // `unconfiguredValue`'s exact-match baseline (still exercised here via + // the sibling notification `enabled` row). + const clean = diffWith( + {}, + { + auth: { + mailer_subjects_confirmation: "Confirm your email address", + mailer_subjects_password_changed_notification: "Your password was changed", + mailer_notifications_password_changed_enabled: false, + }, + }, + ); + expect(clean.changes).toEqual([]); + + const drifted = diffWith( + {}, + { + auth: { + mailer_subjects_confirmation: "Whatever the platform renders today", + mailer_notifications_password_changed_enabled: true, + }, + }, + ); + expect( + changeAt(drifted.changes, ["auth", "email", "template", "confirmation", "subject"]), + ).toBeUndefined(); + expect( + changeAt(drifted.changes, ["auth", "email", "notification", "password_changed", "enabled"]), + ).toMatchObject({ class: "remote_only", remote: true }); + }); + + test("a declared mailer subject still classifies normally against the remote", () => { + // `platformRendered` only suppresses while the local projection is + // silent — a locally DECLARED subject compares like any other field. + const differs = diffWith( + { auth: { email: { template: { confirmation: { subject: "Welcome to ACME" } } } } }, + { auth: { mailer_subjects_confirmation: "Confirm your email address" } }, + ); + expect( + changeAt(differs.changes, ["auth", "email", "template", "confirmation", "subject"]), + ).toMatchObject({ + class: "update", + local: "Welcome to ACME", + remote: "Confirm your email address", + declared: true, + }); + + const missing = diffWith( + { auth: { email: { template: { confirmation: { subject: "Welcome to ACME" } } } } }, + { auth: {} }, + ); + expect( + changeAt(missing.changes, ["auth", "email", "template", "confirmation", "subject"]), + ).toMatchObject({ class: "local_only", local: "Welcome to ACME" }); + }); + + test("every comparable path without a config-side baseline makes a deliberate choice", () => { + // Registry-driven guard for the remote_only suppression baseline: for + // each comparable path the default config's projection AND the raw + // default config are silent on, either its row declares the platform's + // `unconfiguredValue` (and a remote report equal to it classifies clean), + // or its row is `platformRendered` (and ANY remote report classifies + // clean, unconditionally), or the platform's unconfigured report is + // structural ABSENCE (sentinel-pruned SMTP/captcha/SMS/hook siblings, + // sparse postgres_settings) and a zero-form remote — which absence-class + // paths never receive — must REPORT rather than be silently swallowed by + // type-level zero inference. + const defaults = fromConfigDocument(getDefaultCliConfig()); + const raw = getDefaultCliConfig(); + const valueAt = (root: unknown, path: ReadonlyArray): unknown => { + let current: unknown = root; + for (const segment of path) { + if ( + typeof current !== "object" || + current === null || + Array.isArray(current) || + !Object.hasOwn(current, segment) + ) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; + }; + const rowFor = (path: ReadonlyArray) => + projectConfigMappingRows.find( + (row) => + row.configPath.length === path.length && + row.configPath.every((segment, index) => segment === path[index]), + ); + + const baselineless = comparableProjectConfigPaths.filter( + (path) => (valueAt(defaults, path) ?? valueAt(raw, path)) === undefined, + ); + expect(baselineless.length).toBeGreaterThan(0); + + for (const path of baselineless) { + const row = rowFor(path); + expect(row, path.join(".")).toBeDefined(); + if (row !== undefined && row.unconfiguredValue !== undefined) { + // The declared unconfigured value classifies clean... + const projected: Record = {}; + let cursor = projected; + for (const segment of path.slice(0, -1)) { + cursor[segment] = {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1] as string] = row.unconfiguredValue; + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}), document: {} }, + remote: projected, + }); + expect(changeAt(result.changes, path), path.join(".")).toBeUndefined(); + } else if (row !== undefined && row.platformRendered === true) { + // ...a platform-rendered row classifies clean for ANY remote value, + // since it has no local default to compare against — verify with an + // arbitrary string, not the one the platform happens to send today. + const projected: Record = {}; + let cursor = projected; + for (const segment of path.slice(0, -1)) { + cursor[segment] = {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1] as string] = "an arbitrary platform-rendered value"; + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}), document: {} }, + remote: projected, + }); + expect(changeAt(result.changes, path), path.join(".")).toBeUndefined(); + } else { + // ...and a path relying on structural absence must not silently + // swallow a zero-form value if the platform ever starts reporting + // one: inject a zero-form leaf directly into the remote projection + // (bypassing the normalizer, which today omits these paths) and + // assert it REPORTS. + const projected: Record = {}; + let cursor = projected; + for (const segment of path.slice(0, -1)) { + cursor[segment] = {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1] as string] = ""; + const result = diffProjectConfig({ + local: { config: decodeCliConfig({}), document: {} }, + remote: projected, + }); + expect(changeAt(result.changes, path), path.join(".")).toMatchObject({ + class: "remote_only", + }); + } + } + }); + + test("undeclared providers reporting their unconfigured state are not drift", () => { + const result = diffWith( + {}, + { auth: { external_github_enabled: false, external_github_client_id: "" } }, + ); + expect(result.changes.filter((change) => change.path.includes("github"))).toEqual([]); + }); + + test("declared value the response does not carry is local_only", () => { + const result = diffWith( + { auth: { site_url: "https://local.example.com" } }, + // auth block present but without site_url. + { auth: {} }, + ); + expect(changeAt(result.changes, ["auth", "site_url"])).toMatchObject({ + class: "local_only", + local: "https://local.example.com", + remote: undefined, + declared: true, + }); + }); + + test("a wholly absent block turns its declared properties local_only", () => { + const result = diffWith({ db: { settings: { max_connections: 120 } } }, {}); + expect(changeAt(result.changes, ["db", "settings", "max_connections"])).toMatchObject({ + class: "local_only", + local: 120, + }); + }); + + test("unmanaged declared properties are never reported", () => { + const result = diffWith( + { + studio: { port: 55555 }, + api: { port: 4321 }, + realtime: { max_header_length: 8192 }, + local_smtp: { enabled: true }, + }, + { api: {}, realtime: { max_concurrent_users: 5 } }, + ); + expect(result.changes).toEqual([]); + }); + + test("a declared path the projection cannot push surfaces in unmanaged, never as a false clean", () => { + // `auth.oauth_server` is dropped from the document projection entirely — + // push has no oauth_server handling — so a declared `enabled = true` + // disagreeing with the remote's `false` cannot be a change entry. It must + // surface in `unmanaged` so the clean changes list is visibly partial. + const result = diffWith( + { auth: { oauth_server: { enabled: true } } }, + { auth: { oauth_server_enabled: false } }, + ); + expect(result.changes).toEqual([]); + expect(result.unmanaged).toContainEqual(["auth", "oauth_server", "enabled"]); + }); + + test("an unmanaged path is excluded from classification even when the remote AGREES with it", () => { + // The disagreeing case above happened to pass even before the loop + // structurally excluded unmanaged paths, because the remote's `false` + // coincidentally matched the schema's own disabled-by-default baseline. + // `true`/`true` does not: it proves the exclusion itself, not an + // accidental baseline match — ADR 0022's "unmanaged paths can never + // classify either" applies even when local and remote agree. + const result = diffWith( + { auth: { oauth_server: { enabled: true } } }, + { auth: { oauth_server_enabled: true } }, + ); + expect(result.changes).toEqual([]); + expect(result.unmanaged).toContainEqual(["auth", "oauth_server", "enabled"]); + expect(result.counts.total).toBe(0); + }); + + test("an unmanaged path is excluded from classification even when the remote DIFFERS from it", () => { + // Live repro: config.toml declares storage.analytics disabled with a + // max_namespaces value, the platform reports it enabled with a different + // value — `storage.analytics` is dropped from the document projection + // entirely while disabled, so neither path may become a `remote_only` + // change, only `unmanaged`. + const result = diffWith( + { storage: { analytics: { enabled: false, max_namespaces: 5 } } }, + { storage: { features: { iceberg_catalog: { enabled: true, max_namespaces: 10 } } } }, + ); + expect(changeAt(result.changes, ["storage", "analytics", "enabled"])).toBeUndefined(); + expect(changeAt(result.changes, ["storage", "analytics", "max_namespaces"])).toBeUndefined(); + expect(result.unmanaged).toContainEqual(["storage", "analytics", "enabled"]); + expect(result.unmanaged).toContainEqual(["storage", "analytics", "max_namespaces"]); + }); + + test("declared siblings of a disabled container surface in unmanaged", () => { + // Push writes only the disable sentinel for a disabled SMTP block, so a + // declared host is never communicated — the projection prunes it and the + // unmanaged list says so. + const result = diffWith( + { auth: { email: { smtp: { enabled: false, host: "mail.example.com" } } } }, + { auth: {} }, + ); + expect(result.unmanaged).toContainEqual(["auth", "email", "smtp", "host"]); + }); + + test("an undeclared config is fully managed", () => { + const result = diffWith({}, { auth: {} }); + expect(result.unmanaged).toEqual([]); + }); + + test("sequence arrays register reordering as drift", () => { + // api.schemas is order-significant (the first entry is PostgREST's + // default schema), so local ["public","extensions"] vs the wire's + // "extensions,public" is a real difference — in both declared and + // undeclared classifications. + const result = diffWith( + { api: { schemas: ["public", "extensions"] } }, + { api: { db_schema: "extensions,public" } }, + ); + expect(changeAt(result.changes, ["api", "schemas"])).toMatchObject({ class: "update" }); + + const searchPath = diffWith( + { api: { extra_search_path: ["public", "extensions"] } }, + { api: { db_extra_search_path: "extensions,public" } }, + ); + expect(changeAt(searchPath.changes, ["api", "extra_search_path"])).toMatchObject({ + class: "update", + }); + }); + + test("set-semantics arrays ignore element order", () => { + // additional_redirect_urls is membership-only — its registry row opts + // into set equality. + const result = diffWith( + { auth: { additional_redirect_urls: ["https://b.example.com", "https://a.example.com"] } }, + { auth: { uri_allow_list: "https://a.example.com,https://b.example.com" } }, + ); + expect(changeAt(result.changes, ["auth", "additional_redirect_urls"])).toBeUndefined(); + }); + + test("record keys containing dots survive the classification", () => { + // sms.test_otp is keyed by phone numbers — segment-array paths keep the + // key intact where a dotted-string round-trip would silently lose it. + const declared = { + auth: { + sms: { + enable_confirmations: true, + test_otp: { "415.2127777": "111111" }, + }, + }, + }; + const result = diffWith(declared, { + auth: { sms_test_otp: "415.2127777=999999" }, + }); + expect(changeAt(result.changes, ["auth", "sms", "test_otp", "415.2127777"])).toMatchObject({ + class: "update", + local: "111111", + remote: "999999", + }); + }); + + test("byte-size values converge across representations", () => { + // Local "50MiB" and the wire's byte count both canonicalize through the + // convergence normalizers (ADR 0021), so they compare equal. + const equal = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 52428800 } }, + ); + expect(changeAt(equal.changes, ["storage", "file_size_limit"])).toBeUndefined(); + + const differing = diffWith( + { storage: { file_size_limit: "50MiB" } }, + { storage: { file_size_limit: 1048576 } }, + ); + expect(changeAt(differing.changes, ["storage", "file_size_limit"])).toMatchObject({ + class: "update", + }); + }); + + test("declared secret values are masked, never compared, never counted", () => { + const declared = { + auth: { + external: { github: { enabled: true, client_id: "id", secret: "env(GITHUB_SECRET)" } }, + }, + }; + const result = diffWith(declared, { + auth: { external_github_enabled: true, external_github_client_id: "id" }, + }); + expect(result.masked).toContainEqual(["auth", "external", "github", "secret"]); + expect(changeAt(result.changes, ["auth", "external", "github", "secret"])).toBeUndefined(); + expect(result.counts).toEqual({ update: 0, remote_only: 0, local_only: 0, total: 0 }); + }); + + test("undeclared secrets are neither masked nor reported", () => { + const result = diffWith({}, { auth: { smtp_pass: "hmac-of-something" } }); + expect(result.masked).toEqual([]); + expect(result.changes.filter((change) => change.path.includes("pass"))).toEqual([]); + }); + + test("env references annotate the change with every involved variable", () => { + const result = diffWith({ api: { max_rows: 500 } }, { api: { max_rows: 1000 } }, [ + { path: ["api", "max_rows"], source: "environment", envVariables: ["PGRST_MAX_ROWS"] }, + ]); + expect(changeAt(result.changes, ["api", "max_rows"])).toMatchObject({ + envVariables: ["PGRST_MAX_ROWS"], + }); + }); + + test("changes are ordered by path and counts add up", () => { + const result = diffWith( + { api: { max_rows: 5 }, auth: { site_url: "https://local.example.com" } }, + { api: { max_rows: 6 }, auth: {}, database: { postgres_settings: { work_mem: "64MB" } } }, + ); + const joined = result.changes.map((change) => change.path.join("\u0000")); + expect(joined).toEqual([...joined].sort()); + expect(result.counts.update).toBe(1); + expect(result.counts.remote_only).toBe(1); + expect(result.counts.local_only).toBe(1); + expect(result.counts.total).toBe(3); + }); +}); + +describe("isEqualConfigValue", () => { + test("sequence semantics by default", () => { + expect(isEqualConfigValue(["a", "b"], ["a", "b"])).toBe(true); + expect(isEqualConfigValue(["a", "b"], ["b", "a"])).toBe(false); + expect(isEqualConfigValue(["1"], [1])).toBe(true); + expect(isEqualConfigValue(["a"], ["a", "a"])).toBe(false); + }); + + test("set semantics on request are membership-only", () => { + expect(isEqualConfigValue(["a", "b"], ["b", "a"], "set")).toBe(true); + // Duplicates carry no meaning for a set-mode field — identical + // membership with different duplicate counts is NOT drift. + expect(isEqualConfigValue(["a", "a", "b"], ["a", "b", "b"], "set")).toBe(true); + expect(isEqualConfigValue(["a", "a"], ["a", "b"], "set")).toBe(false); + expect(isEqualConfigValue(["a", "b"], ["a"], "set")).toBe(false); + }); + + test("type-aware scalars", () => { + expect(isEqualConfigValue("8080", 8080)).toBe(true); + expect(isEqualConfigValue(8080, "8080")).toBe(true); + expect(isEqualConfigValue("true", true)).toBe(true); + expect(isEqualConfigValue(false, "false")).toBe(true); + expect(isEqualConfigValue("", 0)).toBe(false); + expect(isEqualConfigValue("8080x", 8080)).toBe(false); + expect(isEqualConfigValue(undefined, "")).toBe(false); + }); +}); diff --git a/packages/config/src/config-document.ts b/packages/config/src/config-document.ts index 5718ae7777..a178f1eeb2 100644 --- a/packages/config/src/config-document.ts +++ b/packages/config/src/config-document.ts @@ -13,6 +13,12 @@ export type CliConfigValueSource = "environment" | "local" | "remote"; export interface CliConfigValueOrigin { readonly path: ReadonlyArray; readonly source: CliConfigValueSource; + /** + * For `"environment"` origins: the env var names the `env()` reference + * resolved from (one array literal may draw on several, so this is always + * a list — consumers must never have to split a joined string). + */ + readonly envVariables?: ReadonlyArray; } export interface LoadedCliConfig { diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 45614165a4..76eb7f52f1 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -512,7 +512,9 @@ describe("src/internal.ts export surface", () => { [ "AUTH_HOOK_NAMES", "ENV_CAPTURE_REGEX", + "diffProjectConfig", "loadCliConfig", + "projectConfigApiBlockKeys", "projectConfigMappingRows", "resolveCliConfigSubtree", "resolveCliConfigValue", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 8734c5ecff..3b3dd3c670 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -16,6 +16,15 @@ * implementations. */ export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; +export { + type ConfigChange, + type ConfigChangeClass, + type ConfigChangeCounts, + type ConfigChangeSet, + type DiffProjectConfigOptions, + diffProjectConfig, +} from "./config-diff.ts"; +export { projectConfigApiBlockKeys } from "./project-config/api-attributes.ts"; export { AUTH_HOOK_NAMES, unmappedSecretApiPaths } from "./project-config/registry-auth.ts"; export { projectConfigMappingRows } from "./project-config/registry.ts"; export { type ProjectConfigMappingRow } from "./project-config/registry-row.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 30f2c1e6db..12a2cfa2fd 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -526,7 +526,7 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( const goViperCompat = options?.goViperCompat ?? false; const interpolateDocument = ( document: unknown, - onResolvedEnv?: (path: ReadonlyArray) => void, + onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void, ): unknown => interpolateEnvReferencesAgainstSchema(document, cliProjectEnv?.values ?? {}, CliConfigSchema, { goViperCompat, @@ -574,9 +574,11 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( // that path, but correctness on the match+`env()` path matters more than // avoiding it. const resolvedEnvironmentPaths: Array = []; + const resolvedEnvironmentNames = new Map>(); documentForDecode = isObject(documentForDecode) - ? interpolateDocument(documentForDecode, (path) => { + ? interpolateDocument(documentForDecode, (path, envNames) => { resolvedEnvironmentPaths.push(Array.from(path)); + resolvedEnvironmentNames.set(pathKey(Array.from(path)), envNames); }) : documentForDecode; @@ -621,7 +623,12 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( : localPathKeys.has(key) ? "local" : undefined; - return source === undefined ? [] : [{ path, source }]; + if (source === undefined) { + return []; + } + const envVariables = + source === "environment" ? resolvedEnvironmentNames.get(key) : undefined; + return [{ path, source, ...(envVariables === undefined ? {} : { envVariables }) }]; }) : []; diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 9c43681a96..5232775110 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -218,7 +218,7 @@ function substituteEnvLeaf( value: string, env: Readonly>, goViperCompat: boolean, -): { readonly value: string; readonly resolved: boolean } { +): { readonly value: string; readonly resolved: boolean; readonly envName?: string } { const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return { value, resolved: false }; @@ -229,10 +229,10 @@ function substituteEnvLeaf( // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the // `env(KEY)` literal exactly like an unset key, rather than substituting "". - if (resolved === undefined || resolved === "") { + if (envName === undefined || resolved === undefined || resolved === "") { return { value, resolved: false }; } - return { value: resolved, resolved: true }; + return { value: resolved, resolved: true, envName }; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -258,22 +258,30 @@ function walk( ast: SchemaAST.AST | null, goViperCompat: boolean, path: ReadonlyArray, - onResolvedEnv: ((path: ReadonlyArray) => void) | undefined, + onResolvedEnv: + | ((path: ReadonlyArray, envNames: ReadonlyArray) => void) + | undefined, ): unknown { if (Array.isArray(document)) { - let resolved = false; + // Element-level resolutions are reported once, at the array's own path — + // one array literal may draw on several env vars, so the names collect. + const envNames: Array = []; const onResolvedArrayEnv = onResolvedEnv === undefined ? undefined - : () => { - resolved = true; + : (_: ReadonlyArray, resolvedNames: ReadonlyArray) => { + for (const envName of resolvedNames) { + if (!envNames.includes(envName)) { + envNames.push(envName); + } + } }; const result = document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); return walk(item, env, child, goViperCompat, [...path, String(index)], onResolvedArrayEnv); }); - if (resolved) { - onResolvedEnv?.(path); + if (envNames.length > 0) { + onResolvedEnv?.(path, envNames); } return result; } @@ -297,8 +305,8 @@ function walk( const interpolation = substituteEnvLeaf(document, env, goViperCompat); const substituted = interpolation.value; - if (interpolation.resolved) { - onResolvedEnv?.(path); + if (interpolation.resolved && interpolation.envName !== undefined) { + onResolvedEnv?.(path, [interpolation.envName]); } const expected = ast === null ? "unknown" : leafExpectedType(ast); @@ -357,7 +365,10 @@ export function interpolateEnvReferencesAgainstSchema( schema: { readonly ast: SchemaAST.AST }, options?: { readonly goViperCompat?: boolean; - readonly onResolvedEnv?: (path: ReadonlyArray) => void; + /** Fires per resolved leaf with the substituting env vars' names (array + * leaves report once at the array path, collecting every element's + * variable — one array literal may draw on several). */ + readonly onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void; }, ): unknown { return walk( diff --git a/packages/config/src/project-config/api-attributes.ts b/packages/config/src/project-config/api-attributes.ts index 2352cccfd5..2f448ce122 100644 --- a/packages/config/src/project-config/api-attributes.ts +++ b/packages/config/src/project-config/api-attributes.ts @@ -261,3 +261,15 @@ export const ProjectConfigApiAttributesSchema = Schema.Struct({ }); export type ProjectConfigApiAttributes = typeof ProjectConfigApiAttributesSchema.Type; + +/** + * The per-service block keys of the v2 project-config resource's + * `data.attributes`, in alphabetical order — derived from the mirror schema's + * own key set so consumers never hand-copy the block list (a hand-copied list + * reports a newly-learned block "not returned" forever, test-green). The + * package owns the response shape; a consumer rendering comparison scope + * (CLI-2156's scope line) reads it from here. + */ +export const projectConfigApiBlockKeys: ReadonlyArray = Object.keys( + ProjectConfigApiAttributesSchema.fields, +).sort(); diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index a6d1be5329..df087c6e3e 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -735,6 +735,10 @@ const coreRows: ReadonlyArray = [ ? undefined : splitCommaSeparated(expectString(value, ["auth", "uri_allow_list"])), normalizeDocument: canonicalizeCommaJoinedArray, + // GoTrue treats the allow list as membership only — reordering the URLs + // changes nothing at runtime, unlike the sequence-semantics CSV arrays + // (`api.schemas`, `api.extra_search_path`). + arrayEquality: "set", unit: "csv → string[]", }, uintRow(["auth", "jwt_expiry"], "jwt_exp"), @@ -787,8 +791,18 @@ const rateLimitRows: ReadonlyArray = [ // SESSIONS (auth.sync.ts:1400-1408) const sessionsRows: ReadonlyArray = [ - hoursDurationRow(["auth", "sessions", "timebox"], "sessions_timebox"), - hoursDurationRow(["auth", "sessions", "inactivity_timeout"], "sessions_inactivity_timeout"), + // GoTrue reports 0 hours for a session bound that was never configured, and + // the transform canonicalizes that to the string "0s" — declare it here so + // the diff baseline recognizes the canonicalized form (a type-level zero + // check would miss it and flag every untouched project). + { + ...hoursDurationRow(["auth", "sessions", "timebox"], "sessions_timebox"), + unconfiguredValue: "0s", + }, + { + ...hoursDurationRow(["auth", "sessions", "inactivity_timeout"], "sessions_inactivity_timeout"), + unconfiguredValue: "0s", + }, ]; // EMAIL (auth.sync.ts:1548-1562) @@ -913,9 +927,15 @@ const EMAIL_TEMPLATE_NAMES = [ "reauthentication", ] as const; -const templateRows: ReadonlyArray = EMAIL_TEMPLATE_NAMES.map((name) => - stringRow(["auth", "email", "template", name, "subject"], `mailer_subjects_${name}`), -); +const templateRows: ReadonlyArray = EMAIL_TEMPLATE_NAMES.map((name) => ({ + ...stringRow(["auth", "email", "template", name, "subject"], `mailer_subjects_${name}`), + // `mailer_subjects_*` is a platform-rendered string with no meaningful + // local default — a prior version of this row pinned the provisioning + // string recorded from a prod fixture as `unconfiguredValue`, which broke + // the moment the platform reworded its default (and can never be + // simultaneously correct for every environment platform-side). + platformRendered: true, +})); // Email notifications ×7 (auth.sync.ts:1491-1525) @@ -931,14 +951,25 @@ const EMAIL_NOTIFICATION_NAMES = [ const notificationRows: ReadonlyArray = EMAIL_NOTIFICATION_NAMES.flatMap( (name) => [ - boolRow( - ["auth", "email", "notification", name, "enabled"], - `mailer_notifications_${name}_enabled`, - ), - stringRow( - ["auth", "email", "notification", name, "subject"], - `mailer_subjects_${name}_notification`, - ), + { + ...boolRow( + ["auth", "email", "notification", name, "enabled"], + `mailer_notifications_${name}_enabled`, + ), + // Every account-change notification defaults to disabled (supabase/auth + // `NotificationsConfiguration`, `default:"false"` on each field) — the + // config schema declares no default, so the diff baseline needs the + // platform's own unconfigured reading here. + unconfiguredValue: false, + }, + { + ...stringRow( + ["auth", "email", "notification", name, "subject"], + `mailer_subjects_${name}_notification`, + ), + // Same platform-rendered rationale as the template subjects above. + platformRendered: true, + }, ], ); diff --git a/packages/config/src/project-config/registry-row.ts b/packages/config/src/project-config/registry-row.ts index a568dc5b96..6d8cb51a9c 100644 --- a/packages/config/src/project-config/registry-row.ts +++ b/packages/config/src/project-config/registry-row.ts @@ -85,6 +85,47 @@ export interface ProjectConfigMappingRow { * counts as mapped for `unmappedApiFields`. */ readonly isSecret?: boolean; + /** + * Equality semantics for an array-valued row when a diff consumer compares + * the two projections (`../config-diff.ts`). Whether an array is a set or a + * sequence is per-field wire knowledge, so it lives here with the rest of + * the field's semantics. `"sequence"` — the default when absent — treats + * element order as meaningful: `api.schemas`' first entry is PostgREST's + * default schema and `api.extra_search_path` is a literal `search_path` + * whose order is resolution order, so a reordering changes runtime behavior + * and must register as drift. `"set"` opts a row out for arrays whose wire + * semantics are order-free (`auth.additional_redirect_urls`). Defaulting to + * sequence over-reports rather than under-reports when a new array row + * forgets to choose. + */ + readonly arrayEquality?: "set" | "sequence"; + /** + * The value the platform reports at `configPath` for a project that never + * configured this feature, expressed in CONFIG-space (post-`transform`) — + * e.g. the `"0s"` an unset `sessions.timebox` canonicalizes to, or the + * provisioning-default mailer subjects (pinned by the recorded + * `GET /v1/projects/{ref}/config/auth` fixtures under + * `apps/cli-e2e/fixtures/recorded/`). `../config-diff.ts` uses this as the + * last `remote_only`-suppression baseline tier for paths the default config + * (and its convergence projection) is silent on: a remote report equal to + * this value is the platform's spelling of "unconfigured", not drift. A row + * without it (and without any other baseline) over-reports rather than + * guesses — "unconfigured" is never inferred from type-level zero values, + * because canonicalization can turn a platform zero into a non-zero shape + * (`sessions_timebox: 0` arrives as the string `"0s"`). + */ + readonly unconfiguredValue?: unknown; + /** + * The platform authors/renders this value itself; there is no local + * default. When the local projection carries no value at this path, any + * remote value is the platform's own rendering — never `remote_only` + * drift. A locally DECLARED value still classifies normally. Unlike + * {@link unconfiguredValue}, which suppresses one pinned baseline value, + * this suppresses unconditionally — the right choice for a rendered string + * with no fixed spelling (e.g. a mailer subject line), where pinning a + * baseline breaks the moment the platform rewords its own default. + */ + readonly platformRendered?: boolean; /** * Unit/semantics note, e.g. `"csv → string[]"` or `"seconds → duration * string"`. Documentation-only — never read at runtime. diff --git a/packages/config/src/project-config/registry.ts b/packages/config/src/project-config/registry.ts index 0f12ccbd74..75b2cbfde9 100644 --- a/packages/config/src/project-config/registry.ts +++ b/packages/config/src/project-config/registry.ts @@ -314,12 +314,16 @@ const dbSectionRows: ReadonlyArray = [ configPath: ["db", "network_restrictions", "allowed_cidrs"], apiPath: networkRestrictionsAllowedCidrsPath, transform: (value) => filterCidrAddresses(value, networkRestrictionsAllowedCidrsPath, "v4"), + // Matches `config push`'s own order-sensitive network-restrictions diff. + arrayEquality: "sequence", unit: "type-tagged {address,type}[] → filtered string[] (v4)", }, { configPath: ["db", "network_restrictions", "allowed_cidrs_v6"], apiPath: networkRestrictionsAllowedCidrsPath, transform: (value) => filterCidrAddresses(value, networkRestrictionsAllowedCidrsPath, "v6"), + // Matches `config push`'s own order-sensitive network-restrictions diff. + arrayEquality: "sequence", unit: "type-tagged {address,type}[] → filtered string[] (v6)", }, // Deliberately unmapped (no faithful counterpart): database.