Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<redacted>"`.
- **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`.
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/docs/supabase/config/diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 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.

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.

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 with per-change paths as segment arrays) or the global `-o json|yaml|toml|env` flag.
59 changes: 7 additions & 52 deletions apps/cli/src/legacy/commands/branches/branches.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
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,
LegacyBranchesGetNetworkError,
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,
Expand All @@ -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 });
}
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/commands/branches/get/get.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-branch-ref.resolver.ts";
import type { LegacyBranchesGetFlags } from "./get.command.ts";

type BranchDetail = typeof V1GetABranchConfigOutput.Type;
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/legacy/commands/config/config.command.ts
Original file line number Diff line number Diff line change
@@ -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]),
);
124 changes: 124 additions & 0 deletions apps/cli/src/legacy/commands/config/diff/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# `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 |
| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<workdir>/supabase/config.toml` | TOML | always, before any network call (missing file or parse error aborts, exit 1); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay |
| `<workdir>/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` |
| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref for a branch-name `--project-ref` |
| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check only, 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 |
| ---------------------------------------------- | ------ | ---------------------------------------------------------------------- |
| `<workdir>/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 |
| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--project-ref` is not a ref/UUID; 404 → "branch not found" |
| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution) |

## 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).

| 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` | missing or malformed `supabase/config.toml` |
| `1` | unknown branch (branch-name `--project-ref` 404) |
| `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref |
| `1` | remote config read failure (network or unexpected status) |

## Output

Diagnostics on **stderr**: `Comparing against …` (resolved target + local
scope, i.e. `[remotes.<name>]` or `base config`) before the fetch, then
`Comparison scope: <blocks>` listing the blocks the response carried (missing
blocks are called out). The payload is on **stdout**.

### `--output-format text`

One block per difference (`<path> [update|remote-only|local-only]` with
`local:`/`remote:` lines; unset renders `(unset)` / `(not returned)`, an
undeclared path with a schema default renders `<value> (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: … (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 masked/unmanaged
caveats too, so echoing it never claims "in sync" 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)

Honored, and takes priority over `--output-format` (Legacy Shell Invariant
#6): `-o json|yaml|toml|env` encodes the same structured payload the
`--output-format json` envelope carries (TOML omits `null`-valued entries —
TOML has no null; env flattens to SCREAMING_SNAKE keys with arrays collapsing
to empty strings, the established `godotenv` shape). stdout is payload-pure in
every machine mode; diagnostics stay on stderr. `-o pretty` (and no `-o`)
falls through to `--output-format` handling.

## 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.<name>]` 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 rather than treated as an error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This claim (and ADR 0022's "partially-populated responses degrade … instead of an error") doesn't hold: V2GetProjectConfigOutput makes all six blocks — and their keys — required, so a missing block or key fails the typed decode inside the API client before any of this leniency runs. Confirmed live: staging doesn't return storage.database_pool_mode yet, and the command hard-fails on every invocation with

failed to read project config: SchemaError(Missing key
  at ["data"]["attributes"]["storage"]["database_pool_mode"])

— i.e. the command is currently broken against staging, and a permission-truncated response (the case the ADR names) surfaces as an opaque SchemaError. Consequences: the scope line's "(not returned: …)" branch is unreachable in production (it prints the constant six-block list on every run), and two diff.format.unit.test.ts cases exercise unreachable states.

Pick one: loosen the contract (make blocks/keys optional, matching auth's leniency — the stated intent) and keep the scope machinery, or delete the scope machinery and correct this doc + ADR 0022. Don't leave the doc asserting behaviour the contract forbids.

Loading
Loading