From 993caf3cd526147b966df7bc0b1d39141bf6328a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 11:11:59 +0100 Subject: [PATCH 01/22] feat(cli): port supabase stop and status commands to native TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Go-proxy stubs for `stop`/`status` with native Effect implementations that talk directly to Docker/Podman via subprocess, replicating Go's label-filtering and container-naming scheme byte-for-byte. Legacy `start` is still Go-proxied, so this intentionally does not go through `@supabase/stack/effect`'s daemon-based orchestration model — that substrate is incompatible with the real containers Go's binary creates. Adds shared Docker/config infrastructure (`legacy-docker-lifecycle`, `legacy-go-jwt`, `legacy-local-config-values`, `legacy-api-url`) used by both commands, and fixes several correctness issues found during review: stdout colorization keyed off the wrong stream's TTY status, `--override-name` incorrectly leaking into pretty-mode output (Go ignores it there), and the `--backup`/`--no-backup` formula not matching Go's actual (dead-flag) behavior. Fixes CLI-1324 --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/status/SIDE_EFFECTS.md | 167 +++-- .../legacy/commands/status/status.command.ts | 42 +- .../legacy/commands/status/status.errors.ts | 35 + .../legacy/commands/status/status.handler.ts | 219 +++++- .../status/status.integration.test.ts | 480 ++++++++++++++ .../legacy/commands/status/status.pretty.ts | 276 ++++++++ .../status/status.pretty.unit.test.ts | 267 ++++++++ .../legacy/commands/status/status.values.ts | 248 +++++++ .../status/status.values.unit.test.ts | 388 +++++++++++ .../src/legacy/commands/stop/SIDE_EFFECTS.md | 104 ++- .../src/legacy/commands/stop/stop.command.ts | 26 +- .../src/legacy/commands/stop/stop.errors.ts | 50 ++ .../src/legacy/commands/stop/stop.handler.ts | 238 ++++++- .../commands/stop/stop.integration.test.ts | 627 +++++++++++++++++- apps/cli/src/legacy/shared/legacy-api-url.ts | 26 + apps/cli/src/legacy/shared/legacy-colors.ts | 33 +- .../legacy/shared/legacy-colors.unit.test.ts | 50 ++ .../src/legacy/shared/legacy-container-cli.ts | 60 +- .../shared/legacy-container-cli.unit.test.ts | 51 +- .../src/legacy/shared/legacy-docker-ids.ts | 47 ++ .../shared/legacy-docker-ids.unit.test.ts | 45 +- .../legacy/shared/legacy-docker-lifecycle.ts | 240 +++++++ .../legacy-docker-lifecycle.unit.test.ts | 314 +++++++++ apps/cli/src/legacy/shared/legacy-go-jwt.ts | 38 ++ .../legacy/shared/legacy-go-jwt.unit.test.ts | 65 ++ .../shared/legacy-local-config-values.ts | 111 ++++ .../legacy-local-config-values.unit.test.ts | 110 +++ .../shared/legacy-storage-credentials.ts | 19 +- .../src/shared/cli/hidden-flag.unit.test.ts | 10 +- 30 files changed, 4240 insertions(+), 150 deletions(-) create mode 100644 apps/cli/src/legacy/commands/status/status.errors.ts create mode 100644 apps/cli/src/legacy/commands/status/status.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.ts create mode 100644 apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.ts create mode 100644 apps/cli/src/legacy/commands/status/status.values.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.errors.ts create mode 100644 apps/cli/src/legacy/shared/legacy-api-url.ts create mode 100644 apps/cli/src/legacy/shared/legacy-colors.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts create mode 100644 apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.ts create mode 100644 apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f5969bd199..7d73ba4d0b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -269,8 +269,8 @@ Legend: | `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | | `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | | `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | | `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | | `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | | `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index e27d895e35..4d9579644d 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -8,9 +8,9 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | ## API Routes @@ -18,65 +18,158 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither this command nor any of its dependencies make a Management API call — everything is +resolved from local `config.toml` and the local Docker daemon. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | + +`docker` (or `podman` as a fallback) must be on `PATH`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — status displayed | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — status displayed | +| `0` | **`--ignore-health-check` is set** — skips the health assertion below entirely, so an unhealthy/not-running db never fails the command | +| `1` | `supabase/config.toml` missing or malformed | +| `1` | a malformed `--override-name` entry | +| `1` | listing running containers failed (Docker daemon unreachable, etc.) | +| `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | +| `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | ## Output ### `--output-format text` (Go CLI compatible) -Prints a table of service names, container IDs, images, and URLs. +Default (`-o` unset or `-o pretty`): a stderr banner, then 5 grouped rounded-border tables on +stdout. Empty rows (a value with nothing resolved) and entirely empty groups are skipped; a +blank line follows every group, rendered or not. ``` - supabase local development setup is running. - - API URL: http://127.0.0.1:54321 - GraphQL URL: http://127.0.0.1:54321/graphql/v1 - S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3 - DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres - Studio URL: http://127.0.0.1:54323 - Inbucket URL: http://127.0.0.1:54324 - JWT secret: super-secret-jwt-token-with-at-least-32-characters-long - anon key: ... -service_role key: ... - S3 Access Key: 625729a08b95bf1b7ff351a663f3a23c - S3 Secret Key: 850181e4652dd023b7a98c58ae0d2d34bd487ee0ead3abe0 - S3 Region: local +supabase local development setup is running. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │ +│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │ +╰─────────────┴────────────────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────────────────────╮ +│ 📦 Storage (S3) │ +├────────────┬──────────────────────────────────────────────────────────────────┤ +│ URL │ http://127.0.0.1:54321/storage/v1/s3 │ +│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │ +│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │ +│ Region │ local │ +╰────────────┴──────────────────────────────────────────────────────────────────╯ ``` -### `--output-format json` +Group table cells are colored on a TTY (Aqua for links, Yellow for keys, Green for labels, bold +headers); colors are stripped on non-TTY/piped output. + +`Stopped services: [ ...]` is written to stderr (Go slice format, e.g. +`[supabase_storage_test supabase_studio_test]`) whenever one of the 13 expected service +containers isn't in the running set. + +### `-o env` + +`KEY="VALUE"` lines (unquoted for integer-looking values), one per resolved field, sorted by +key — see `legacy-go-output.encoders.ts`'s `encodeEnv`. + +### `-o json` ```json { "API_URL": "http://127.0.0.1:54321", - "DB_URL": "postgresql://...", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", "ANON_KEY": "...", "SERVICE_ROLE_KEY": "...", + "PUBLISHABLE_KEY": "...", + "SECRET_KEY": "...", "JWT_SECRET": "...", - "S3_ACCESS_KEY": "...", - "S3_SECRET_KEY": "...", - "S3_REGION": "local" + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "...", + "S3_PROTOCOL_REGION": "local" } ``` -### `--output-format stream-json` +Top-level keys sorted alphabetically, 2-space indent, trailing newline (Go `encoding/json` +parity). Fields whose owning service is disabled or excluded are omitted entirely (not emitted +as `null`/`""`). + +### `-o yaml` / `-o toml` + +Same value set as `-o json`, encoded via `encodeYaml`/`encodeToml`. + +### `--output-format json` / `stream-json` (when `-o` is unset or `pretty`) -Not applicable. +Additive — no Go CLI equivalent. Emits the same resolved value map via +`output.success("", values)` / the NDJSON `result` event. ## Notes -- `--override-name` flag overrides specific variable names in env output. -- `-o env` output format uses KEY=VALUE pairs. -- `-o json` output format uses a JSON object. -- `-o pretty` (default) uses the human-readable table format. +- `-o`/`--output` (`env|pretty|json|toml|yaml`) takes priority over `--output-format` whenever + it is set, matching the Go-parity checklist's dual-output-flag rule. `-o pretty` (or `-o` + unset) falls through to `--output-format`'s text/json/stream-json handling. +- `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` remaps a single field's output KEY; the + value and group layout are unaffected. An unknown key or a malformed (non `KEY=VALUE`) entry + fails with `LegacyStatusOverrideParseError`. This only affects the `env`/`json`/`toml`/`yaml` + (`printStatus`) output path — matching Go, the pretty table (`-o pretty` or unset) always + renders with un-overridden names, since Go's `PrettyPrint` unmarshals a fresh, empty `EnvSet{}` + rather than reusing the CLI-supplied, override-populated `CustomName` (`status.go:236-243`). +- When neither `docker` nor `podman` can be spawned at all, the error message names the actual + root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or + Podman and ensure it is on PATH") rather than a generic "failed to ..." string. +- `--exclude ` (hidden) omits a service from the value map by container id only — + Go additionally supports excluding by Docker image short-name, which has no `@supabase/config` + schema equivalent to check against, so that branch is not replicated (documented divergence). +- `--ignore-health-check` (hidden) skips the db container health assertion entirely and always + exits `0`, matching Go's early-return in `Run()`. +- Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a + Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the + latter uses a different issuer, expiry, and claim order that would not match what Go prints + for local dev keys. +- `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; + Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced + identically in `legacy-local-config-values.ts`. +- No e2e test is planned for this command: there is no Docker-daemon-free golden path, and the + e2e harness (`runSupabase()`) does not provision a real local stack. This is a scope reduction + relative to the Linear issue's "E2E compatibility test added" checkbox; see the port plan for + the full justification. diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index f10c14fafe..d08c723235 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -1,5 +1,14 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; const config = { @@ -8,10 +17,32 @@ const config = { Flag.withDescription("Override specific variable names."), Flag.withDefault([] as ReadonlyArray), ), + exclude: Flag.string("exclude").pipe( + Flag.atLeast(0), + Flag.withDescription("Names of containers to omit from output."), + Flag.withDefault([] as ReadonlyArray), + Flag.withHidden, + ), + ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( + Flag.withDescription("Ignore unhealthy services and exit 0"), + Flag.withHidden, + ), } as const; export type LegacyStatusFlags = CliCommand.Command.Config.Infer; +// `status` makes no Management API calls (Go's status needs no access token), so +// it deliberately avoids `legacyManagementApiRuntimeLayer` — mirrors `unlink`'s +// runtime shape. `legacyCliConfigLayer` is exposed at the top level directly +// (nothing else in this runtime needs to consume it internally). +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStatusRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["status"]), +); + export const legacyStatusCommand = Command.make("status", config).pipe( Command.withDescription("Show status of local Supabase containers."), Command.withShortDescription("Show status of local Supabase containers"), @@ -25,5 +56,14 @@ export const legacyStatusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), - Command.withHandler((flags) => legacyStatus(flags)), + Command.withHandler((flags) => + legacyStatus(flags).pipe( + withLegacyCommandInstrumentation({ + flags, + outputFormats: LEGACY_RESOURCE_OUTPUT_FORMATS, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyStatusRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts new file mode 100644 index 0000000000..786e7fa8ff --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -0,0 +1,35 @@ +import { Data } from "effect"; + +/** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ + readonly message: string; +}> {} + +/** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ +export class LegacyStatusOverrideParseError extends Data.TaggedError( + "LegacyStatusOverrideParseError", +)<{ + readonly message: string; +}> {} + +/** Inspecting the db container failed for a reason other than "not found". */ +export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ + readonly message: string; +}> {} + +/** The db container is absent or present but not in the `running` state. */ +export class LegacyStatusDbNotRunningError extends Data.TaggedError( + "LegacyStatusDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** The db container is running but its Docker health check is not `healthy`. */ +export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ + readonly message: string; +}> {} + +/** Listing running containers by label failed. */ +export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index da50886773..2444a07d54 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,10 +1,217 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Option } from "effect"; + +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyInspectContainerState, + legacyListContainersByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../shared/legacy-go-output.encoders.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { + LegacyStatusConfigLoadError, + LegacyStatusDbInspectError, + LegacyStatusDbNotReadyError, + LegacyStatusDbNotRunningError, + LegacyStatusListError, + LegacyStatusOverrideParseError, +} from "./status.errors.ts"; +import { legacyRenderStatusPretty } from "./status.pretty.ts"; +import { + LEGACY_STATUS_FIELDS, + legacyStatusContainerIds, + legacyStatusValues, +} from "./status.values.ts"; + +/** + * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a + * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` + * pair whose `KEY` matches one of the 18 known `CustomName` field keys. + */ +function parseOverrides( + entries: ReadonlyArray, +): Effect.Effect, LegacyStatusOverrideParseError> { + const knownKeys = new Set(LEGACY_STATUS_FIELDS.map((field) => field.fieldKey)); + const overrides = new Map(); + for (const entry of entries) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, + }), + ); + } + const key = entry.slice(0, separatorIndex); + const value = entry.slice(separatorIndex + 1); + if (!knownKeys.has(key)) { + return Effect.fail( + new LegacyStatusOverrideParseError({ + message: `unknown override-name key: ${key}`, + }), + ); + } + overrides.set(key, value); + } + return Effect.succeed(overrides); +} + +/** Go's `fmt.Fprintln(os.Stderr, "Stopped services:", stopped)` slice format. */ +function formatGoStringSlice(items: ReadonlyArray): string { + return `[${items.join(" ")}]`; +} export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyStatusFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["status"]; - for (const override of flags.overrideName) args.push("--override-name", override); - yield* proxy.exec(args); + const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + yield* Effect.gen(function* () { + // 1. `status` always needs config, unlike `stop` (status.go:99-103). + const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + if (loaded === null) { + return yield* Effect.fail( + new LegacyStatusConfigLoadError({ + message: "failed to read config: supabase/config.toml not found", + }), + ); + } + const config = loaded.config; + + // 2. status has no --project-id flag; resolution is always env → toml → workdir basename. + const projectId = legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ); + const dbContainerId = localDbContainerId(projectId); + + // 3. Health check, skipped entirely with --ignore-health-check (status.go:104-108). + // Go's `assertContainerHealthy` never special-cases "not found" — an absent + // container fails `ContainerInspect` itself, which surfaces as the generic + // inspect error (status.go:147-150), not the "not running" branch (which + // only applies to a present-but-stopped container, status.go:150-151). + if (!flags.ignoreHealthCheck) { + const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( + Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), + ); + if (state === "absent") { + return yield* Effect.fail( + new LegacyStatusDbInspectError({ + message: "failed to inspect container health: no such container", + }), + ); + } + if (!state.running) { + return yield* Effect.fail( + new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }), + ); + } + if (state.health !== undefined && state.health !== "healthy") { + return yield* Effect.fail( + new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }), + ); + } + } + + // 4. List running containers, diff against the 13 expected service ids + // (status.go:125-145), and report any that are stopped. + const filterValue = legacyCliProjectFilterValue(projectId); + const runningNames = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: false, + format: "names", + }).pipe(Effect.mapError((cause) => new LegacyStatusListError({ message: cause.message }))); + const runningSet = new Set(runningNames); + const serviceIds = legacyServiceContainerIds(projectId); + const stopped = serviceIds.filter((id) => !runningSet.has(id)); + if (stopped.length > 0) { + yield* output.raw(`Stopped services: ${formatGoStringSlice(stopped)}\n`, "stderr"); + } + + // 5. Merge health-derived exclusions with the user's --exclude flag. + const excluded = [...stopped, ...flags.exclude]; + + // 6. Build the value map (Go's toValues()). + const containerIds = legacyStatusContainerIds(projectId); + const hostname = legacyGetHostname(); + + // 7. --override-name KEY=VALUE parsing. + const overrides = yield* parseOverrides(flags.overrideName); + + // `names` is intentionally unused here: the pretty-mode branch below + // recomputes with an empty override map (matching Go), and every other + // branch only needs `values`. + const { values } = legacyStatusValues(config, containerIds, hostname, excluded, overrides); + + // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over + // --output-format; -o pretty/unset falls through to text/json/stream-json. + const goFmt = Option.getOrUndefined(goOutputFlag); + + if (goFmt === "env") { + yield* output.raw(encodeEnv(values) + "\n"); + return; + } + if (goFmt === "json") { + yield* output.raw(encodeGoJson(values)); + return; + } + if (goFmt === "toml") { + yield* output.raw(encodeToml(values) + "\n"); + return; + } + if (goFmt === "yaml") { + yield* output.raw(encodeYaml(values)); + return; + } + + // goFmt is undefined or "pretty" — defer to TS --output-format for json/stream-json, + // otherwise render the grouped rounded-table (Go's `-o pretty` default). + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("", values); + return; + } + + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + // Go's `PrettyPrint` (`status.go:236-243`) unmarshals a FRESH, empty + // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the + // CLI-supplied, override-populated `names` — `--override-name` only ever + // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. + // Recompute with an empty override map so the rendered table matches Go + // exactly instead of leaking `--override-name` into pretty-mode output. + const pretty = legacyStatusValues(config, containerIds, hostname, excluded, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts new file mode 100644 index 0000000000..ac39129679 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -0,0 +1,480 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; +import type { LegacyStatusFlags } from "./status.command.ts"; +import { legacyStatus } from "./status.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); + +function flags(overrides: Partial = {}): LegacyStatusFlags { + return { + overrideName: [], + exclude: [], + ignoreHealthCheck: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** Same routing-by-argv mock spawner shape as `stop.integration.test.ts`. */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(5000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); +const HEALTHY_DB_STATE = JSON.stringify({ Status: "running", Health: { Status: "healthy" } }); + +/** + * Default happy-path router: db container inspect reports healthy+running, `ps` + * (names format) lists every one of the 13 expected services as running. + */ +function defaultRoute( + opts: { + readonly runningNames?: ReadonlyArray; + readonly dbInspectStdout?: string; + readonly dbInspectExitCode?: number; + readonly dbInspectStderr?: ReadonlyArray; + } = {}, +) { + const runningNames = opts.runningNames ?? ALL_RUNNING_NAMES; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "container" && args[1] === "inspect") { + return { + exitCode: opts.dbInspectExitCode ?? 0, + stdout: [opts.dbInspectStdout ?? HEALTHY_DB_STATE], + stderr: opts.dbInspectStderr, + }; + } + if (args[0] === "ps") return { stdout: runningNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly skipConfig?: boolean; + readonly configContents?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configContents); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, + }); + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), + ); + + return { workdir, out, telemetry, child, layer }; +} + +describe("legacy status integration", () => { + it.live("shows the running stack as a pretty table", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🔧 Development Tools"); + expect(out.stdoutText).toContain("🌐 APIs"); + expect(out.stdoutText).toContain("⛁ Database"); + expect(out.stdoutText).toContain("🔑 Authentication Keys"); + expect(out.stdoutText).toContain("📦 Storage (S3)"); + expect(out.stdoutText).toContain("postgresql://postgres:postgres@"); + expect(out.stderrText).not.toContain("Stopped services:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("skips the db health check with --ignore-health-check", () => { + const { layer, child } = setup({ + route: (args) => { + // db inspect would fail if called; ps still needs to succeed. + if (args[0] === "container" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "ps") return { stdout: ALL_RUNNING_NAMES }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ ignoreHealthCheck: true })); + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect")).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports stopped services on stderr", () => { + const { layer, out } = setup({ + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const missing = ALL_RUNNING_NAMES[0]; + expect(out.stderrText).toContain(`Stopped services: [${missing}]`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is missing entirely", () => { + const { layer } = setup({ skipConfig: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when both docker and podman are missing", () => { + // Neither container runtime can be spawned at all — distinct from a spawned + // process exiting non-zero (covered by the malformed/unhealthy scenarios + // above). + const { layer } = setup({ failSpawnFor: () => true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ dockerMissing: true }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the last + // matching record for a given argv is the successful one. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when listing running containers errors", () => { + const { layer } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: [HEALTHY_DB_STATE] }; + } + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is not running", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectStdout: JSON.stringify({ Status: "exited" }) }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbNotRunningError"); + expect(serialized).toContain(localDbContainerId("demo")); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is absent", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectExitCode: 1, + dbInspectStderr: ["Error: No such container: x"], + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the db container is unhealthy", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ Status: "running", Health: { Status: "starting" } }), + }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when db inspect errors for a reason other than not-found", () => { + const { layer } = setup({ + route: defaultRoute({ dbInspectExitCode: 1, dbInspectStderr: ["permission denied"] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs env vars with -o env", () => { + const { layer, out } = setup({ goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).toContain("DB_URL="); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs a json object with -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.DB_URL).toContain("postgresql://postgres:postgres@"); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits excluded services from -o json", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs yaml with -o yaml", () => { + const { layer, out } = setup({ goOutput: Option.some("yaml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL:"); + }).pipe(Effect.provide(layer)); + }); + + it.live("outputs toml with -o toml", () => { + const { layer, out } = setup({ goOutput: Option.some("toml") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain("API_URL ="); + }).pipe(Effect.provide(layer)); + }); + + it.live("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.API_URL).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on a malformed --override-name entry", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags({ overrideName: ["not-a-kv-pair"] }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails on an --override-name entry with an unknown field key", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result with --output-format json when -o is unset", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ API_URL: "http://127.0.0.1:54321" }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + }).pipe(Effect.provide(layer)); + }); + + it.live("-o takes priority over --output-format when both are passed", () => { + const { layer, out } = setup({ format: "json", goOutput: Option.some("env") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + // -o env wins: raw KEY="VALUE" text on stdout, not a structured success message. + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" ? { exitCode: 1 } : { exitCode: 0 }, + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStatus(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.pretty.ts b/apps/cli/src/legacy/commands/status/status.pretty.ts new file mode 100644 index 0000000000..2999e4ef50 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.ts @@ -0,0 +1,276 @@ +import { legacyAqua, legacyBold, legacyGreen, legacyYellow } from "../../shared/legacy-colors.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +/** + * Port of Go's `PrettyPrint` / `OutputGroup.printTable` + * (`apps/cli-go/internal/status/status.go:236-392`), reproducing + * `tablewriter.NewTable` with `tw.StyleRounded` byte-for-byte for the fixed + * 5-group, 2-column layout `status` needs. This is not a general tablewriter + * port — column sizing, wrapping, and merge behavior are only implemented to the + * extent this command's rounded box needs them. + * + * Column 0 (the label column) is capped at 16 display columns + * (`ColMaxWidths.PerColumn[0] = 16`, `status.go:344`); a label wider than that + * word-wraps across multiple lines, leaving column 1 blank on the continuation + * lines (verified against a real `tablewriter@v1.1.4` render — see the port + * plan). None of the fixed labels below reach 17 characters today, so this is + * defensive parity rather than an observed case. + * + * This does not reuse `legacy/output/legacy-glamour-table.ts` — that helper + * byte-matches Go's `glamour.RenderTable(..., AsciiStyle)`, a single ASCII table + * with a different border style used by other commands. `status`'s Go source + * renders with `tablewriter`/`tw.StyleRounded` into 5 separate grouped, colored, + * Unicode-rounded-box tables, which is a different rendering contract entirely. + * + * Every color call below styles text written to **stdout** (via `output.raw` + * with no stream argument in `status.handler.ts`), so each one explicitly passes + * `process.stdout` to `legacy-colors.ts`'s helpers — they default to + * `process.stderr`, which would check the wrong stream's TTY status here. + */ + +type OutputKind = "text" | "link" | "key"; + +interface OutputItem { + readonly label: string; + readonly value: string; + readonly kind: OutputKind; +} + +interface OutputGroup { + readonly name: string; + readonly items: ReadonlyArray; +} + +const COLUMN_0_MAX_WIDTH = 16; + +/** + * Builds the 5 fixed groups Go's `PrettyPrint` declares (`status.go:245-285`), + * looking up each label's value by output KEY from the resolved value map — + * `--override-name` remaps the KEY but never the group layout, matching Go + * (`values[names.StudioURL]`, not a hardcoded default name). + */ +function buildGroups( + values: Readonly>, + names: LegacyStatusOutputNames, +): ReadonlyArray { + const at = (key: string) => values[key] ?? ""; + return [ + { + name: "🔧 Development Tools", + items: [ + { label: "Studio", value: at(names.studioUrl), kind: "link" }, + { label: "Mailpit", value: at(names.mailpitUrl), kind: "link" }, + { label: "MCP", value: at(names.mcpUrl), kind: "link" }, + ], + }, + { + name: "🌐 APIs", + items: [ + { label: "Project URL", value: at(names.apiUrl), kind: "link" }, + { label: "REST", value: at(names.restUrl), kind: "link" }, + { label: "GraphQL", value: at(names.graphqlUrl), kind: "link" }, + { label: "Edge Functions", value: at(names.functionsUrl), kind: "link" }, + ], + }, + { + name: "⛁ Database", + items: [{ label: "URL", value: at(names.dbUrl), kind: "link" }], + }, + { + name: "🔑 Authentication Keys", + items: [ + { label: "Publishable", value: at(names.publishableKey), kind: "key" }, + { label: "Secret", value: at(names.secretKey), kind: "key" }, + ], + }, + { + name: "📦 Storage (S3)", + items: [ + { label: "URL", value: at(names.storageS3Url), kind: "link" }, + { label: "Access Key", value: at(names.storageS3AccessKeyId), kind: "key" }, + { label: "Secret Key", value: at(names.storageS3SecretAccessKey), kind: "key" }, + { label: "Region", value: at(names.storageS3Region), kind: "text" }, + ], + }, + ]; +} + +/** + * Display width, matching `go-runewidth`'s treatment closely enough for this + * command's inputs: URLs/keys/labels are always plain ASCII, so every rune is + * width 1. The only non-ASCII runes ever rendered are the 5 fixed group-title + * emoji, whose exact rendered widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} + * below rather than computed generically (avoids taking a full Unicode + * East-Asian-Width dependency for a 5-value constant table). + */ +function displayWidth(text: string): number { + return [...text].length; +} + +/** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ +const HEADER_DISPLAY_WIDTH: Readonly> = { + "🔧 Development Tools": 20, + "🌐 APIs": 7, + "⛁ Database": 10, + "🔑 Authentication Keys": 22, + "📦 Storage (S3)": 15, +}; + +/** + * Exported only for direct unit coverage of the fallback branch (a group title + * outside the 5-entry {@link HEADER_DISPLAY_WIDTH} table) — every call site in + * this file only ever passes one of those 5 fixed titles. + */ +export function legacyStatusHeaderWidth(name: string): number { + return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); +} + +/** + * Greedy word-wrap to `width` columns, mirroring tablewriter's column wrapping. + * Exported only for direct unit coverage of the >16-char defensive-wrap branch + * (see the file-level doc comment) — none of this command's real labels reach + * that width today, so `legacyRenderStatusPretty` never exercises it end to end. + */ +export function legacyWrapStatusLabel(text: string, width: number): ReadonlyArray { + if (displayWidth(text) <= width) return [text]; + const words = text.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (displayWidth(candidate) <= width) { + current = candidate; + } else { + if (current.length > 0) lines.push(current); + current = word; + } + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : [text]; +} + +/** + * Value coloring, mirroring the `switch row.Type` in `printTable` + * (`status.go:372-377`): `Link` → Aqua, `Key` → Yellow, `Text` → unstyled (the + * switch has no `Text` case, so `value` keeps its raw pre-switch assignment). + */ +function colorValue(kind: OutputKind, value: string): string { + switch (kind) { + case "link": + return legacyAqua(value, process.stdout); + case "key": + return legacyYellow(value, process.stdout); + case "text": + return value; + } +} + +interface ColumnLayout { + readonly col0Padded: number; + readonly col1Padded: number; + readonly targetInner: number; +} + +/** + * Computes the padded column widths and total inner (header) width for a group, + * mirroring tablewriter's column-sizing pass: each column is sized from its + * widest content cell (col 0 capped at 16), then both columns widen evenly + * (col 0 taking the larger half of an odd remainder) if the header text is + * wider than the data-driven layout. Exported only for direct unit coverage of + * the header-widens-the-table branch — none of this command's 5 fixed group + * titles are wider than their data today, so `legacyRenderStatusPretty` never + * exercises it end to end (see the file-level doc comment). + */ +export function legacyStatusColumnLayout( + headerWidthValue: number, + col0Contents: ReadonlyArray, + col1Contents: ReadonlyArray, +): ColumnLayout { + const col0Content = Math.min( + COLUMN_0_MAX_WIDTH, + Math.max(...col0Contents.map((text) => displayWidth(text))), + ); + const col1Content = Math.max(...col1Contents.map((text) => displayWidth(text))); + + let col0Padded = col0Content + 2; + let col1Padded = col1Content + 2; + const dataInner = col0Padded + 1 + col1Padded; + const targetInner = Math.max(dataInner, headerWidthValue + 2); + const extra = targetInner - dataInner; + if (extra > 0) { + col0Padded += Math.ceil(extra / 2); + col1Padded += Math.floor(extra / 2); + } + return { col0Padded, col1Padded, targetInner }; +} + +function renderGroupTable(group: OutputGroup): string | undefined { + const rows = group.items.filter((item) => item.value.length > 0); + if (rows.length === 0) return undefined; + + // Column 0 wraps at 16; column 1 is never capped (Go only sets PerColumn[0]). + // Kept as plain text here — color is applied only after padding, below, so an + // ANSI escape is never counted toward the padded display width. + const wrappedRows = rows.map((row) => ({ + lines: legacyWrapStatusLabel(row.label, COLUMN_0_MAX_WIDTH), + kind: row.kind, + value: row.value, + })); + + const { col0Padded, col1Padded, targetInner } = legacyStatusColumnLayout( + legacyStatusHeaderWidth(group.name), + rows.map((row) => row.label), + rows.map((row) => row.value), + ); + const col0Width = col0Padded - 2; + const col1Width = col1Padded - 2; + + // Pad on the plain text first, then apply color/bold — an active ANSI escape + // must never be counted toward the padded display width. + const pad = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - displayWidth(text))); + // The header uses `headerWidth` (the hardcoded emoji-aware width table) rather + // than `displayWidth`, so its padding lines up with the border math above, + // which sized `targetInner` off the same `headerWidth` call. + const padHeader = (text: string, width: number) => + text + " ".repeat(Math.max(0, width - legacyStatusHeaderWidth(text))); + + const lines: string[] = []; + lines.push(`╭${"─".repeat(col0Padded + 1 + col1Padded)}╮`); + lines.push(`│ ${legacyBold(padHeader(group.name, targetInner - 2), process.stdout)} │`); + lines.push(`├${"─".repeat(col0Padded)}┬${"─".repeat(col1Padded)}┤`); + for (const row of wrappedRows) { + row.lines.forEach((line, index) => { + // Only the first wrapped line carries the value; Go's continuation lines + // (from a >16-char label wrapping) leave column 1 blank. + const labelCell = legacyGreen(pad(line, col0Width), process.stdout); + const paddedValue = pad(index === 0 ? row.value : "", col1Width); + const valueCell = index === 0 ? colorValue(row.kind, paddedValue) : paddedValue; + lines.push(`│ ${labelCell} │ ${valueCell} │`); + }); + } + lines.push(`╰${"─".repeat(col0Padded)}┴${"─".repeat(col1Padded)}╯`); + return lines.join("\n"); +} + +/** + * Port of Go's `PrettyPrint` (`status.go:236-294`): renders the 5 fixed groups + * as rounded-border tables, skipping empty rows and empty groups, with a blank + * line after every group (rendered or not — Go's loop always + * `fmt.Fprintln(w)`s after a nil-error `printTable`, even when nothing rendered). + */ +export function legacyRenderStatusPretty( + values: Readonly>, + names: LegacyStatusOutputNames, +): string { + const groups = buildGroups(values, names); + const lines: string[] = []; + for (const group of groups) { + const table = renderGroupTable(group); + if (table !== undefined) { + lines.push(table); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts new file mode 100644 index 0000000000..be44ac4c75 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.pretty.unit.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyRenderStatusPretty, + legacyStatusColumnLayout, + legacyStatusHeaderWidth, + legacyWrapStatusLabel, +} from "./status.pretty.ts"; +import type { LegacyStatusOutputNames } from "./status.values.ts"; + +// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which +// no-ops on a real non-TTY stream but the vitest process presents its stderr +// as color-capable. Strip escapes so these assertions target the plain +// structural output — the golden contract per the port plan — not whichever +// TTY heuristic the test runner happens to report. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +// Default (un-overridden) output names, matching `status.values.ts`'s +// `resolveOutputNames` with an empty override map — the KEYs the pretty +// renderer looks values up by. +const NAMES: LegacyStatusOutputNames = { + apiUrl: "API_URL", + restUrl: "REST_URL", + graphqlUrl: "GRAPHQL_URL", + storageS3Url: "STORAGE_S3_URL", + mcpUrl: "MCP_URL", + functionsUrl: "FUNCTIONS_URL", + dbUrl: "DB_URL", + studioUrl: "STUDIO_URL", + mailpitUrl: "MAILPIT_URL", + publishableKey: "PUBLISHABLE_KEY", + secretKey: "SECRET_KEY", + storageS3AccessKeyId: "S3_PROTOCOL_ACCESS_KEY_ID", + storageS3SecretAccessKey: "S3_PROTOCOL_ACCESS_KEY_SECRET", + storageS3Region: "S3_PROTOCOL_REGION", +}; + +const FULL_VALUES: Record = { + API_URL: "http://127.0.0.1:54321", + REST_URL: "http://127.0.0.1:54321/rest/v1", + GRAPHQL_URL: "http://127.0.0.1:54321/graphql/v1", + STORAGE_S3_URL: "http://127.0.0.1:54321/storage/v1/s3", + MCP_URL: "http://127.0.0.1:54321/mcp", + FUNCTIONS_URL: "http://127.0.0.1:54321/functions/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + STUDIO_URL: "http://127.0.0.1:54323", + MAILPIT_URL: "http://127.0.0.1:54324", + PUBLISHABLE_KEY: "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + SECRET_KEY: "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", + S3_PROTOCOL_ACCESS_KEY_SECRET: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + S3_PROTOCOL_REGION: "local", +}; + +describe("legacyRenderStatusPretty", () => { + // Byte-for-byte parity with a real `tablewriter@v1.1.4` + `tw.StyleRounded` + // render of Go's `PrettyPrint` group layout (verified by running the actual + // vendored Go module against this exact value set — see the port plan). + it("matches the Go rounded-table fixture for a fully running stack", () => { + const out = stripAnsi(legacyRenderStatusPretty(FULL_VALUES, NAMES)); + + const expected = [ + "╭──────────────────────────────────────╮", + "│ 🔧 Development Tools │", + "├─────────┬────────────────────────────┤", + "│ Studio │ http://127.0.0.1:54323 │", + "│ Mailpit │ http://127.0.0.1:54324 │", + "│ MCP │ http://127.0.0.1:54321/mcp │", + "╰─────────┴────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────╮", + "│ 🌐 APIs │", + "├────────────────┬─────────────────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "│ REST │ http://127.0.0.1:54321/rest/v1 │", + "│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │", + "│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │", + "╰────────────────┴─────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + "", + "╭──────────────────────────────────────────────────────────────╮", + "│ 🔑 Authentication Keys │", + "├─────────────┬────────────────────────────────────────────────┤", + "│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │", + "│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │", + "╰─────────────┴────────────────────────────────────────────────╯", + "", + "╭───────────────────────────────────────────────────────────────────────────────╮", + "│ 📦 Storage (S3) │", + "├────────────┬──────────────────────────────────────────────────────────────────┤", + "│ URL │ http://127.0.0.1:54321/storage/v1/s3 │", + "│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │", + "│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │", + "│ Region │ local │", + "╰────────────┴──────────────────────────────────────────────────────────────────╯", + "", + ].join("\n"); + + expect(out).toBe(expected); + }); + + // Byte-for-byte parity with a real render of a single-row group (Database), + // confirming the header-vs-single-short-row column sizing. All other groups + // are empty in this fixture, so only the Database box should appear. + it("matches the Go rounded-table fixture for a single-row group", () => { + const out = stripAnsi( + legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), + ); + + const expectedTable = [ + "╭───────────────────────────────────────────────────────────────╮", + "│ ⛁ Database │", + "├─────┬─────────────────────────────────────────────────────────┤", + "│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │", + "╰─────┴─────────────────────────────────────────────────────────╯", + ].join("\n"); + + expect(out).toContain(expectedTable); + expect(out).toBe(["", "", expectedTable, "", "", ""].join("\n")); + }); + + // All other groups are empty in this fixture, so only the APIs box appears + // (only Project URL, the rest of the group's rows are excluded/disabled). + it("matches the Go rounded-table fixture for a partial APIs group", () => { + const out = stripAnsi(legacyRenderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); + + const expectedTable = [ + "╭──────────────────────────────────────╮", + "│ 🌐 APIs │", + "├─────────────┬────────────────────────┤", + "│ Project URL │ http://127.0.0.1:54321 │", + "╰─────────────┴────────────────────────╯", + ].join("\n"); + + expect(out).toBe(["", expectedTable, "", "", "", ""].join("\n")); + }); + + it("skips a row whose value is missing from the value map", () => { + // Only Studio present; Mailpit/MCP absent from the map entirely (excluded + // or disabled upstream in `status.values.ts`) — same as an empty string. + const out = stripAnsi( + legacyRenderStatusPretty({ STUDIO_URL: "http://127.0.0.1:54323" }, NAMES), + ); + + expect(out).toContain("Studio"); + expect(out).not.toContain("Mailpit"); + expect(out).not.toContain("MCP"); + }); + + it("skips an entirely empty group but still emits its trailing blank line", () => { + // Nothing present for Development Tools; only the Database URL is set. + const out = stripAnsi(legacyRenderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, NAMES)); + const lines = out.split("\n"); + + // No rounded-box characters before the Database group's own box. + expect(lines[0]).not.toMatch(/[╭│╰]/); + expect(lines[0]).toBe(""); + expect(out).not.toContain("Development Tools"); + expect(out).toContain("⛁ Database"); + }); + + it("returns only blank lines when every group is empty", () => { + const out = stripAnsi(legacyRenderStatusPretty({}, NAMES)); + // One blank line per group (5 groups), none of them rendering a table. + expect(out).toBe(["", "", "", "", ""].join("\n")); + }); + + // `legacyRenderStatusPretty` is a pure lookup: it renders whatever `values` + // are reachable through `names`' keys, with no opinion on how the caller + // derived either. This is NOT asserting that `--override-name` reaches + // pretty-mode output in production — `status.handler.ts` deliberately always + // calls this function with un-overridden names (matching Go's `PrettyPrint`, + // which unmarshals a fresh empty `EnvSet{}` rather than the CLI's overridden + // `CustomName`). This test only proves the renderer's KEY-based lookup itself + // works correctly for an arbitrary names/values pairing. + it("resolves values through whatever KEY the names parameter specifies", () => { + const overriddenNames: LegacyStatusOutputNames = { + ...NAMES, + apiUrl: "NEXT_PUBLIC_SUPABASE_URL", + }; + const out = stripAnsi( + legacyRenderStatusPretty( + { NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321" }, + overriddenNames, + ), + ); + expect(out).toContain("http://127.0.0.1:54321"); + }); +}); + +// None of `status`'s 18 fixed field labels or 5 fixed group titles are wide +// enough to exercise these two branches through the public +// `legacyRenderStatusPretty` API today (see the file-level doc comment on +// `status.pretty.ts`) — covered directly here as defensive Go-parity logic. +describe("legacyWrapStatusLabel", () => { + it("returns the text unwrapped when it fits within the width", () => { + expect(legacyWrapStatusLabel("Edge Functions", 16)).toEqual(["Edge Functions"]); + }); + + it("word-wraps a label wider than the column width", () => { + expect(legacyWrapStatusLabel("This Is A Very Long Label Name", 16)).toEqual([ + "This Is A Very", + "Long Label Name", + ]); + }); + + it("hard-breaks a single word wider than the column width", () => { + expect(legacyWrapStatusLabel("ThisIsAVeryLongSingleWordLabel", 16)).toEqual([ + "ThisIsAVeryLongSingleWordLabel", + ]); + }); + + it("does not emit a leading empty line when the very first word already overflows", () => { + expect(legacyWrapStatusLabel("SuperLongFirstWord Short", 10)).toEqual([ + "SuperLongFirstWord", + "Short", + ]); + }); + + it("returns the input unchanged for an empty label", () => { + expect(legacyWrapStatusLabel("", 10)).toEqual([""]); + }); + + it("returns the input unchanged for a whitespace-only label wider than the column", () => { + // Every "word" from splitting on spaces is itself empty, so `current` never + // accumulates anything to flush after the loop — the `lines` array stays + // empty and the function falls back to the original text. + expect(legacyWrapStatusLabel(" ", 2)).toEqual([" "]); + }); +}); + +describe("legacyStatusColumnLayout", () => { + it("sizes columns from content alone when the header already fits", () => { + const layout = legacyStatusColumnLayout(10, ["URL"], ["postgresql://short"]); + expect(layout.targetInner).toBe(3 + 2 + 1 + "postgresql://short".length + 2); + }); + + it("widens both columns evenly when the header is wider than the data", () => { + // Base data-driven layout: col0="a"(1+2=3), col1="b"(1+2=3), dataInner=3+1+3=7. + // A 10-char header needs innerWidth=12, so 5 extra columns split 3/2. + const layout = legacyStatusColumnLayout(10, ["a"], ["b"]); + expect(layout.targetInner).toBe(12); + expect(layout.col0Padded).toBe(6); + expect(layout.col1Padded).toBe(5); + }); + + it("caps column 0's content width at 16 even when a label is longer", () => { + const layout = legacyStatusColumnLayout(0, ["a".repeat(30)], ["b"]); + expect(layout.col0Padded).toBe(18); + }); +}); + +describe("legacyStatusHeaderWidth", () => { + it("uses the hardcoded emoji-aware width for a known fixed group title", () => { + expect(legacyStatusHeaderWidth("⛁ Database")).toBe(10); + }); + + it("falls back to code-point length for a title outside the fixed table", () => { + expect(legacyStatusHeaderWidth("Plain Title")).toBe("Plain Title".length); + }); +}); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts new file mode 100644 index 0000000000..6b6fa3d5b9 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -0,0 +1,248 @@ +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; +import { + legacyResolveLocalConfigValues, + type LegacyLocalConfigValues, +} from "../../shared/legacy-local-config-values.ts"; + +/** + * Port of Go's `status.CustomName` + `toValues()` (`internal/status/status.go:29-97`). + * Each field's Go `env:"..."` tag carries two things: the dotted key + * `--override-name =` matches against (`fieldKey` below), and the + * default output env-var name (`defaultName`). `deprecated` fields (`inbucket`, + * `jwt_secret`, `anon_key`, `service_role_key`) are still emitted — Go's + * `deprecated` tag only affects a startup warning it never wires up for `status` + * (only `env.Unmarshal` reads the tag, and it does not warn), so no divergence here. + */ +export interface LegacyStatusField { + readonly fieldKey: string; + readonly defaultName: string; +} + +const API_URL: LegacyStatusField = { fieldKey: "api.url", defaultName: "API_URL" }; +const REST_URL: LegacyStatusField = { fieldKey: "api.rest_url", defaultName: "REST_URL" }; +const GRAPHQL_URL: LegacyStatusField = { fieldKey: "api.graphql_url", defaultName: "GRAPHQL_URL" }; +const STORAGE_S3_URL: LegacyStatusField = { + fieldKey: "api.storage_s3_url", + defaultName: "STORAGE_S3_URL", +}; +const MCP_URL: LegacyStatusField = { fieldKey: "api.mcp_url", defaultName: "MCP_URL" }; +const FUNCTIONS_URL: LegacyStatusField = { + fieldKey: "api.functions_url", + defaultName: "FUNCTIONS_URL", +}; +const DB_URL: LegacyStatusField = { fieldKey: "db.url", defaultName: "DB_URL" }; +const STUDIO_URL: LegacyStatusField = { fieldKey: "studio.url", defaultName: "STUDIO_URL" }; +const INBUCKET_URL: LegacyStatusField = { fieldKey: "inbucket.url", defaultName: "INBUCKET_URL" }; +const MAILPIT_URL: LegacyStatusField = { fieldKey: "mailpit.url", defaultName: "MAILPIT_URL" }; +const PUBLISHABLE_KEY: LegacyStatusField = { + fieldKey: "auth.publishable_key", + defaultName: "PUBLISHABLE_KEY", +}; +const SECRET_KEY: LegacyStatusField = { fieldKey: "auth.secret_key", defaultName: "SECRET_KEY" }; +const JWT_SECRET: LegacyStatusField = { fieldKey: "auth.jwt_secret", defaultName: "JWT_SECRET" }; +const ANON_KEY: LegacyStatusField = { fieldKey: "auth.anon_key", defaultName: "ANON_KEY" }; +const SERVICE_ROLE_KEY: LegacyStatusField = { + fieldKey: "auth.service_role_key", + defaultName: "SERVICE_ROLE_KEY", +}; +const STORAGE_S3_ACCESS_KEY_ID: LegacyStatusField = { + fieldKey: "storage.s3_access_key_id", + defaultName: "S3_PROTOCOL_ACCESS_KEY_ID", +}; +const STORAGE_S3_SECRET_ACCESS_KEY: LegacyStatusField = { + fieldKey: "storage.s3_secret_access_key", + defaultName: "S3_PROTOCOL_ACCESS_KEY_SECRET", +}; +const STORAGE_S3_REGION: LegacyStatusField = { + fieldKey: "storage.s3_region", + defaultName: "S3_PROTOCOL_REGION", +}; + +/** All 18 fields, in `CustomName` struct declaration order. */ +export const LEGACY_STATUS_FIELDS: ReadonlyArray = [ + API_URL, + REST_URL, + GRAPHQL_URL, + STORAGE_S3_URL, + MCP_URL, + FUNCTIONS_URL, + DB_URL, + STUDIO_URL, + INBUCKET_URL, + MAILPIT_URL, + PUBLISHABLE_KEY, + SECRET_KEY, + JWT_SECRET, + ANON_KEY, + SERVICE_ROLE_KEY, + STORAGE_S3_ACCESS_KEY_ID, + STORAGE_S3_SECRET_ACCESS_KEY, + STORAGE_S3_REGION, +]; + +/** The subset of {@link LEGACY_STATUS_FIELDS} the pretty renderer looks up by field. */ +export interface LegacyStatusOutputNames { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly storageS3Url: string; + readonly mcpUrl: string; + readonly functionsUrl: string; + readonly dbUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Resolves each field's output KEY, applying `--override-name =` + * remaps over the Go default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) + * to the replacement output name, mirroring `env.Unmarshal`'s `default=` override. + */ +function resolveOutputNames(overrides: ReadonlyMap): LegacyStatusOutputNames { + const nameFor = (field: LegacyStatusField) => overrides.get(field.fieldKey) ?? field.defaultName; + return { + apiUrl: nameFor(API_URL), + restUrl: nameFor(REST_URL), + graphqlUrl: nameFor(GRAPHQL_URL), + storageS3Url: nameFor(STORAGE_S3_URL), + mcpUrl: nameFor(MCP_URL), + functionsUrl: nameFor(FUNCTIONS_URL), + dbUrl: nameFor(DB_URL), + studioUrl: nameFor(STUDIO_URL), + mailpitUrl: nameFor(MAILPIT_URL), + publishableKey: nameFor(PUBLISHABLE_KEY), + secretKey: nameFor(SECRET_KEY), + storageS3AccessKeyId: nameFor(STORAGE_S3_ACCESS_KEY_ID), + storageS3SecretAccessKey: nameFor(STORAGE_S3_SECRET_ACCESS_KEY), + storageS3Region: nameFor(STORAGE_S3_REGION), + }; +} + +/** + * Container ids `toValues()` gates each group on, taken from + * `legacyServiceContainerIds`'s alias order (`kong`, `auth`, `inbucket`, ..., + * `edge_runtime`, ...) — see `legacy-docker-ids.ts`. + */ +export interface LegacyStatusContainerIds { + readonly kong: string; + readonly auth: string; + readonly inbucket: string; + readonly rest: string; + readonly storage: string; + readonly studio: string; + readonly edgeRuntime: string; +} + +// Positional indices into `legacyServiceContainerIds`'s fixed 13-element +// array (`legacy-docker-ids.ts`'s `GetDockerIds()` order), named so a caller +// never has to destructure the array positionally. +const CONTAINER_INDEX = { + kong: 0, + auth: 1, + inbucket: 2, + rest: 4, + storage: 5, + studio: 8, + edgeRuntime: 9, +} as const; + +/** + * Derives {@link LegacyStatusContainerIds} from `legacyServiceContainerIds`'s + * flat array for a given project id. The array's length and order are a fixed + * Go-parity contract (13 elements, `GetDockerIds()` order), so every named + * index here is guaranteed present — this only exists to give the handler a + * named-field view instead of positional array destructuring. + */ +export function legacyStatusContainerIds(projectId: string): LegacyStatusContainerIds { + const ids = legacyServiceContainerIds(projectId); + const at = (index: number) => ids[index] ?? ""; + return { + kong: at(CONTAINER_INDEX.kong), + auth: at(CONTAINER_INDEX.auth), + inbucket: at(CONTAINER_INDEX.inbucket), + rest: at(CONTAINER_INDEX.rest), + storage: at(CONTAINER_INDEX.storage), + studio: at(CONTAINER_INDEX.studio), + edgeRuntime: at(CONTAINER_INDEX.edgeRuntime), + }; +} + +export interface LegacyStatusValuesResult { + readonly values: Record; + readonly names: LegacyStatusOutputNames; + readonly local: LegacyLocalConfigValues; +} + +/** + * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). + * `excluded` matches by container id only — Go's `ShortContainerImageName` branch + * has no schema equivalent to check against (decision #3 in the port plan). + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const local = legacyResolveLocalConfigValues(config, hostname); + const names = resolveOutputNames(overrides); + const isExcluded = (id: string) => excluded.includes(id); + + const kongEnabled = config.api.enabled && !isExcluded(containerIds.kong); + const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest); + const studioEnabled = config.studio.enabled && !isExcluded(containerIds.studio); + const authEnabled = config.auth.enabled && !isExcluded(containerIds.auth); + const inbucketEnabled = config.local_smtp.enabled && !isExcluded(containerIds.inbucket); + const storageEnabled = config.storage.enabled && !isExcluded(containerIds.storage); + const functionsEnabled = config.edge_runtime.enabled && !isExcluded(containerIds.edgeRuntime); + + // Go always sets db.url unconditionally, before any gating (status.go:52). + const values: Record = { + [names.dbUrl]: local.dbUrl, + }; + + if (kongEnabled) { + values[names.apiUrl] = local.apiUrl; + if (postgrestEnabled) { + values[names.restUrl] = local.restUrl; + values[names.graphqlUrl] = local.graphqlUrl; + } + if (functionsEnabled) { + values[names.functionsUrl] = local.functionsUrl; + } + if (studioEnabled) { + values[names.mcpUrl] = local.mcpUrl; + } + } + if (studioEnabled) { + values[names.studioUrl] = local.studioUrl; + } + if (authEnabled) { + values[names.publishableKey] = local.publishableKey; + values[names.secretKey] = local.secretKey; + values[overrides.get(JWT_SECRET.fieldKey) ?? JWT_SECRET.defaultName] = local.jwtSecret; + values[overrides.get(ANON_KEY.fieldKey) ?? ANON_KEY.defaultName] = local.anonKey; + values[overrides.get(SERVICE_ROLE_KEY.fieldKey) ?? SERVICE_ROLE_KEY.defaultName] = + local.serviceRoleKey; + } + if (inbucketEnabled) { + values[names.mailpitUrl] = local.mailpitUrl; + values[overrides.get(INBUCKET_URL.fieldKey) ?? INBUCKET_URL.defaultName] = local.mailpitUrl; + } + if (storageEnabled && config.storage.s3_protocol.enabled) { + values[names.storageS3Url] = local.storageS3Url; + values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; + values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; + values[names.storageS3Region] = local.storageS3Region; + } + + return { values, names, local }; +} diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts new file mode 100644 index 0000000000..152197e119 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -0,0 +1,388 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + legacyStatusContainerIds, + legacyStatusValues, + type LegacyStatusContainerIds, +} from "./status.values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +const CONTAINER_IDS: LegacyStatusContainerIds = { + kong: "supabase_kong_test", + auth: "supabase_auth_test", + inbucket: "supabase_inbucket_test", + rest: "supabase_rest_test", + storage: "supabase_storage_test", + studio: "supabase_studio_test", + edgeRuntime: "supabase_edge_runtime_test", +}; + +const HOSTNAME = "127.0.0.1"; +const NONE: ReadonlyArray = []; +const NO_OVERRIDES = new Map(); + +describe("legacyStatusValues", () => { + it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { + const config = baseConfig({ + api: { enabled: false }, + studio: { enabled: false }, + auth: { enabled: false }, + local_smtp: { enabled: false }, + storage: { enabled: false }, + edge_runtime: { enabled: false }, + }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(Object.keys(values)).toEqual(["DB_URL"]); + expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); + }); + + describe("api / kong gating", () => { + it("includes API_URL when api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + }); + + it("omits API_URL when api.enabled is false", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits API_URL when the kong container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("omits REST/GraphQL when only the rest container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.rest], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + + it("includes REST/GraphQL when kong and postgrest are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.REST_URL).toBeDefined(); + expect(values.GRAPHQL_URL).toBeDefined(); + }); + }); + + describe("functions gating", () => { + it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + + it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.edgeRuntime], + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); + }); + + describe("studio / mcp gating", () => { + it("includes STUDIO_URL when studio.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("omits STUDIO_URL when studio.enabled is false", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("omits STUDIO_URL when the studio container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.studio], + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + + it("includes MCP_URL only when both kong and studio are enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.MCP_URL).toBeDefined(); + }); + + it("omits MCP_URL when kong is disabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.MCP_URL).toBeUndefined(); + }); + + it("omits MCP_URL when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.MCP_URL).toBeUndefined(); + }); + }); + + describe("auth gating", () => { + it("includes all 5 auth fields when auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.SECRET_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits all 5 auth fields when auth.enabled is false", () => { + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + expect(values.SECRET_KEY).toBeUndefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("omits all 5 auth fields when the auth container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.auth], + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + }); + + describe("inbucket/mailpit gating", () => { + it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.MAILPIT_URL).toBeUndefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + + it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.inbucket], + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); + }); + + describe("storage / s3 gating", () => { + it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_SECRET).toBeDefined(); + expect(values.S3_PROTOCOL_REGION).toBeDefined(); + }); + + it("omits storage S3 fields when storage.enabled is false", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when the storage container id is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.storage], + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + + it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); + }); + }); + + describe("--override-name remapping", () => { + it("remaps a field's output KEY while leaving the value unchanged", () => { + const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.API_URL).toBeUndefined(); + expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + }); + + it("remaps every field independently when multiple overrides are given", () => { + const overrides = new Map([ + ["api.url", "CUSTOM_API_URL"], + ["db.url", "CUSTOM_DB_URL"], + ]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_API_URL).toBeDefined(); + expect(values.CUSTOM_DB_URL).toBeDefined(); + expect(values.API_URL).toBeUndefined(); + expect(values.DB_URL).toBeUndefined(); + }); + + it("leaves unrelated fields at their default name when only one is overridden", () => { + const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.REST_URL).toBeDefined(); + }); + + it("remaps the deprecated auth.jwt_secret/anon_key/service_role_key keys", () => { + const overrides = new Map([ + ["auth.jwt_secret", "CUSTOM_JWT_SECRET"], + ["auth.anon_key", "CUSTOM_ANON_KEY"], + ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], + ]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_JWT_SECRET).toBeDefined(); + expect(values.CUSTOM_ANON_KEY).toBeDefined(); + expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); + expect(values.JWT_SECRET).toBeUndefined(); + expect(values.ANON_KEY).toBeUndefined(); + expect(values.SERVICE_ROLE_KEY).toBeUndefined(); + }); + + it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { + const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); + const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); + expect(values.MAILPIT_URL).toBeDefined(); + expect(values.INBUCKET_URL).toBeUndefined(); + }); + }); + + it("combines stopped-service exclusions with --exclude flag exclusions", () => { + // Both `stopped` (from the health-check diff) and `--exclude` (user flag) + // funnel into the same `excluded` array in the handler; the pure function + // only sees the merged list. + const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + excluded, + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.STUDIO_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + }); +}); + +describe("legacyStatusContainerIds", () => { + it("derives every named field from legacyServiceContainerIds's fixed array order", () => { + const ids = legacyStatusContainerIds("demo"); + expect(ids).toEqual({ + kong: "supabase_kong_demo", + auth: "supabase_auth_demo", + inbucket: "supabase_inbucket_demo", + rest: "supabase_rest_demo", + storage: "supabase_storage_demo", + studio: "supabase_studio_demo", + edgeRuntime: "supabase_edge_runtime_demo", + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 09cf725258..4c918076e5 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -1,16 +1,21 @@ # `supabase stop` +Native TypeScript port of Go's `internal/stop`. Talks directly to Docker via subprocess +(`docker`/`podman`), replicating Go's label-filtering and container-naming scheme +byte-for-byte — it does not go through `@supabase/stack/effect`'s orchestration model +(see the CLI-1324 plan's "Critical architectural finding" for why). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| -------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command — Go parity | ## API Routes @@ -18,37 +23,94 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +Neither `stop` nor its Go counterpart make any Management API call. Everything is local +Docker + local `config.toml`. + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id on the default path (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | resolves `LegacyCliConfig.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml`) | + +`docker`/`podman` must be resolvable on `PATH` (or reachable via the configured Docker +context) — `spawnContainerCli` tries `docker` first and falls back to `podman`. When +neither can be spawned at all, the error message names the actual root cause (e.g. +"docker: command not found (podman also not found) — install Docker Desktop or Podman +and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers stopped | -| `1` | Docker daemon not running or connection error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`LegacyStopMutuallyExclusiveError`) | +| `1` | `config.toml` present but malformed (`LegacyStopConfigLoadError`) — an **absent** file is not an error | +| `1` | listing containers failed (`LegacyStopListError`) | +| `1` | stopping one or more containers failed (`LegacyStopContainerError`) | +| `1` | `docker container prune` failed (`LegacyStopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`LegacyStopVolumePruneError`) | +| `1` | `docker network prune` failed (`LegacyStopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Matches `apps/cli-go/internal/stop/`. Go does not fire any custom telemetry event for +this command. ## Output +Go's `stop` has **no** `-o`/`--output` flag at all, so the Go-compat `LegacyOutputFlag` +is not consulted by this handler — only the TS-native `--output-format` matters here. +This is a harmless, documented divergence: Go would reject an unknown `-o` flag outright. + ### `--output-format text` (Go CLI compatible) -Prints "Stopped supabase local development setup." on success. +- stderr (transient): `Stopping containers...` +- stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan + when the output stream is a TTY, plain otherwise) +- stderr (conditional): when any Docker volume still carries the project's + `com.supabase.cli.project` label after stopping, an additional suggestion line: + - with a project id filter: `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project=` + - with `--all` (empty filter): `Local data are backed up to docker volume. Use docker to show them: docker volume ls --filter label=com.supabase.cli.project` ### `--output-format json` -Not applicable — stop is a local-dev workflow command. +Additive — no Go CLI equivalent. Single JSON object via `Output.success`: + +```json +{ "project_id_filter": "demo", "backup": true } +``` ### `--output-format stream-json` -Not applicable — stop is a local-dev workflow command. +Same payload as `json`, delivered as a `result` NDJSON event. ## Notes -- `--no-backup` deletes all data volumes after stopping. -- `--project-id` targets a specific local project ID to stop. -- `--all` stops all local Supabase instances across all projects on the machine. -- `--project-id` and `--all` are mutually exclusive. -- The hidden `--backup` flag (default true) is the inverse of `--no-backup`. +- `--project-id` and `--all` are **directory-independent** pure Docker-label filters — + neither reads `config.toml`. Only the no-flags default path resolves the project id + from `LegacyCliConfig.workdir` (env → config.toml `project_id` → workdir basename). +- The hidden `--backup` flag exists only for Go CLI surface parity — it has **no effect**. + Go declares it via `flags.Bool("backup", true, ...)` (`cmd/stop.go:26`) but never binds + the return value to a variable, so `RunE` always passes `!noBackup` to `stop.Run` + regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = +flags.noBackup`. `--backup=false` alone does **not** delete volumes; only + `--no-backup` does. +- Volume prune always passes `--all`. Go gates that flag on Docker engine >= 1.42 + (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips the + version check and always passes `--all` because every currently supported Docker + version is far past 1.42. +- Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), + mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before + failing the command (rather than stopping at the first failure), matching Go's + `errors.Join` over the full result set — though the surfaced message is a single fixed + string rather than a joined list of per-container errors, since Docker CLI subprocess + stderr isn't captured per-container the way Go's SDK error is. +- No e2e test is planned: there is no Docker-daemon-free golden path for this command, + and the e2e harness (`runSupabase()`) does not provision a real local stack. See the + CLI-1324 plan's "E2e tests" section for the full justification. diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts index c89e0c3d1d..b69683c700 100644 --- a/apps/cli/src/legacy/commands/stop/stop.command.ts +++ b/apps/cli/src/legacy/commands/stop/stop.command.ts @@ -1,5 +1,13 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStop } from "./stop.handler.ts"; const config = { @@ -24,8 +32,24 @@ const config = { export type LegacyStopFlags = CliCommand.Command.Config.Infer; +// `stop` makes no Management API calls (Go's stop needs no access token) and talks +// directly to Docker, so it deliberately avoids `legacyManagementApiRuntimeLayer` — +// it provides only the services the handler + instrumentation consume. +// `ChildProcessSpawner` is not listed here: it comes from `BunServices` in the root +// runtime (`shared/cli/run.ts`), the same way `gen types`/`unlink` rely on it. +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const legacyStopRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["stop"]), +); + export const legacyStopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), - Command.withHandler((flags) => legacyStop(flags)), + Command.withHandler((flags) => + legacyStop(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(legacyStopRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts new file mode 100644 index 0000000000..273a01c67e --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -0,0 +1,50 @@ +import { Data } from "effect"; + +/** + * `--project-id` and `--all` were both set. Best-effort match of cobra's + * `MarkFlagsMutuallyExclusive` message shape (`stopCmd.MarkFlagsMutuallyExclusive("project-id", + * "all")`, `apps/cli-go/cmd/stop.go`). Cobra isn't vendored in this repo, so the exact + * wording could not be verified against source; this mirrors the same phrasing already + * used for `gen types`'s mutually-exclusive flag groups (`types.handler.ts`). + */ +export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( + "LegacyStopMutuallyExclusiveError", +)<{ + readonly message: string; +}> {} + +/** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ +export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ + readonly message: string; +}> {} + +/** + * Listing containers to stop failed. `stop`-specific wrapper over + * `LegacyDockerLifecycleListError` (see `legacy-docker-lifecycle.ts`) so this command's + * errors are all in one file with a `LegacyStop*` tag, matching the plan's error list. + */ +export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ + readonly message: string; +}> {} + +/** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ +export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ + readonly message: string; +}> {} + +/** `docker container prune` failed. */ +export class LegacyStopContainerPruneError extends Data.TaggedError( + "LegacyStopContainerPruneError", +)<{ + readonly message: string; +}> {} + +/** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ +export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ + readonly message: string; +}> {} + +/** `docker network prune` failed. */ +export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ab7f5f9ad8..a2e6863f0e 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,15 +1,231 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Option, Result } from "effect"; + +import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, +} from "../../shared/legacy-container-cli.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; +import { + LegacyStopConfigLoadError, + LegacyStopContainerError, + LegacyStopContainerPruneError, + LegacyStopListError, + LegacyStopMutuallyExclusiveError, + LegacyStopNetworkPruneError, + LegacyStopVolumePruneError, +} from "./stop.errors.ts"; + +/** + * Resolve the Docker label filter `stop` searches on. Go's flag precedence + * (`stop.go:14-22`): `--all` bypasses config entirely with an empty filter; + * `--project-id` overrides `Config.ProjectId` directly, also bypassing + * config.toml; otherwise `flags.LoadConfig` reads config.toml and + * `Config.ProjectId` (env → toml → workdir basename) is used. + */ +const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( + function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { + if (flags.all) return ""; + if (Option.isSome(flags.projectId)) return flags.projectId.value; + + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still + // resolves a project id via the workdir basename default. Only a + // malformed file (`loadProjectConfig` failing rather than returning + // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. + const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + return legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + loaded?.config.project_id, + cliConfig.workdir, + ); + }, +); export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["stop"]; - if (Option.isSome(flags.projectId)) args.push("--project-id", flags.projectId.value); - // `--backup` defaults to true; only forward when explicitly disabled, which - // matches the Go CLI semantics (`!noBackup` && `--backup=false`). - if (!flags.backup) args.push("--backup=false"); - if (flags.noBackup) args.push("--no-backup"); - if (flags.all) args.push("--all"); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + yield* Effect.gen(function* () { + if (Option.isSome(flags.projectId) && flags.all) { + return yield* Effect.fail( + new LegacyStopMutuallyExclusiveError({ + // Cobra's `validateExclusiveFlagGroups` (spf13/cobra flag_groups.go): + // the group name keeps declaration order (`strings.Join(flagNames, " ")`), + // but the "were all set" list is `sort.Strings`-ed — verified against + // the vendored cobra@v1.10.2 source, not guessed. + message: + "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", + }), + ); + } + + const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliConfig); + // Go's hidden `--backup` flag is declared via `flags.Bool("backup", true, ...)` + // (`cmd/stop.go:26`) but its return value is discarded — never bound to a + // variable, so `RunE` always passes `!noBackup` to `stop.Run` regardless of + // `--backup`'s value. `--backup=false` is a no-op in the real Go binary + // today; only `--no-backup` deletes volumes. Matching that exactly (not the + // seemingly-intended-but-dead semantics of the flag's own description). + const deleteVolumes = flags.noBackup; + const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); + + // Captured (not discarded) so it can be `.fail()`ed or `.clear()`ed below, + // matching the project's established `output.task` usage pattern + // (apps/cli/CLAUDE.md's "always wrap API calls in output.task"). In + // non-interactive/CI runs the spinner never renders, but `.fail()`/`.clear()` + // still resolve cleanly — a discarded handle would otherwise leave a spinner + // that's started but never stopped. A single `Effect.tapError` around the + // whole list/stop/prune sequence (rather than one per step) fails the same + // task on any error without repeating the same branch at every call site. + const stopping = + output.format === "text" ? yield* output.task("Stopping containers...") : undefined; + + yield* Effect.gen(function* () { + const containerIds = yield* legacyListContainersByLabel(spawner, { + projectIdFilter: filterValue, + all: true, + format: "id", + }).pipe(Effect.mapError((cause) => new LegacyStopListError({ message: cause.message }))); + + // Go stops containers concurrently via `WaitAll`, joining every failure + // rather than short-circuiting on the first one (`docker.go:96-146`). + const stopResults = yield* Effect.all( + containerIds.map((id) => containerCliExitCode(spawner, ["stop", id]).pipe(Effect.result)), + { concurrency: "unbounded" }, + ); + const failedStop = stopResults.find( + (result) => Result.isFailure(result) || result.success !== 0, + ); + if (failedStop !== undefined) { + return yield* Effect.fail( + new LegacyStopContainerError({ + message: `failed to stop container: ${ + Result.isFailure(failedStop) + ? legacyDescribeContainerCliFailure(failedStop.failure) + : `exit ${failedStop.success}` + }`, + }), + ); + } + + const containerPruneExitCode = yield* containerCliExitCode(spawner, [ + "container", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopContainerPruneError({ + message: `failed to prune containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (containerPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopContainerPruneError({ message: "failed to prune containers" }), + ); + } + + if (deleteVolumes) { + // Go gates the `--all` filter arg on Docker engine >= 1.42 (`docker.go:120-124`). + // All currently supported Docker versions are well past 1.42, so the TS port + // always passes `--all` — documented divergence, see SIDE_EFFECTS.md Notes. + const volumePruneExitCode = yield* containerCliExitCode(spawner, [ + "volume", + "prune", + "--force", + "--all", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopVolumePruneError({ + message: `failed to prune volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (volumePruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopVolumePruneError({ message: "failed to prune volumes" }), + ); + } + } + + const networkPruneExitCode = yield* containerCliExitCode(spawner, [ + "network", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new LegacyStopNetworkPruneError({ + message: `failed to prune networks: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPruneExitCode !== 0) { + return yield* Effect.fail( + new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), + ); + } + }).pipe(Effect.tapError(() => stopping?.fail() ?? Effect.void)); + + yield* stopping?.clear() ?? Effect.void; + + if (output.format === "text") { + // Written to stdout (no stream arg): `legacyAqua` must target stdout's own + // TTY status, not stderr's — see `legacy-colors.ts`'s doc comment. + yield* output.raw( + `Stopped ${legacyAqua("supabase", process.stdout)} local development setup.\n`, + ); + } else { + yield* output.success("Stopped supabase local development setup.", { + project_id_filter: searchProjectIdFilter, + backup: !deleteVolumes, + }); + } + + // Post-run suggestion (stop.go:26-37): only meaningful in text mode — json/ + // stream-json payloads have no equivalent field to carry this hint. + if (output.format === "text") { + const remainingVolumes = yield* legacyListVolumesByLabel(spawner, filterValue).pipe( + Effect.orElseSucceed(() => []), + ); + if (remainingVolumes.length > 0) { + const listVolumeCommand = + searchProjectIdFilter.length > 0 + ? `docker volume ls --filter label=com.supabase.cli.project=${searchProjectIdFilter}` + : "docker volume ls --filter label=com.supabase.cli.project"; + yield* output.raw( + `Local data are backed up to docker volume. Use docker to show them: ${legacyAqua(listVolumeCommand)}\n`, + "stderr", + ); + } + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index c9c9d83fea..3f56eead39 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,55 +1,614 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyStop } from "./stop.handler.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; -function setupLegacyStop() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); +const tempRoot = useLegacyTempWorkdir("supabase-stop-int-"); + +function flags(overrides: Partial = {}): LegacyStopFlags { + return { + projectId: Option.none(), + backup: true, + noBackup: false, + all: false, + ...overrides, + }; +} + +function writeConfig(workdir: string, projectId: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** + * Routes each spawned invocation to a caller-supplied result by matching argv + * (rather than a fixed call sequence): `stop` issues five distinct docker + * subcommands (`ps`, `stop`, `container prune`, `volume prune`, `network prune`, + * `volume ls`) whose relative order/count varies per scenario (N `stop` calls for + * N listed containers), so a routing table is a better fit than the sequential + * step-array mock `gen types` uses for its single linear pipeline. + */ +function mockRoutedContainerCliSpawner( + route: (args: ReadonlyArray) => RouteResult, + opts: { + readonly dockerMissing?: boolean; + // Fails BOTH docker and podman spawn attempts for argv matching this predicate, + // simulating a runtime that cannot be spawned at all (as opposed to a spawned + // process exiting non-zero) — exercises the `Effect.mapError`/`orElseSucceed` + // spawn-failure branches distinct from the exit-code-checking branches. + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + } = {}, +) { + const spawned: Array = []; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + if (opts.failSpawnFor?.(args) === true) { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + const encoder = new TextEncoder(); + const result = route(args); + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(result.exitCode ?? 0), + ); + }), + ); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +/** Default happy-path router: `ps` lists one container, everything else succeeds empty. */ +function defaultRoute( + opts: { + readonly containerIds?: ReadonlyArray; + readonly volumeNames?: ReadonlyArray; + } = {}, +) { + const containerIds = opts.containerIds ?? ["c1"]; + const volumeNames = opts.volumeNames ?? []; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "ps") return { stdout: containerIds }; + if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + return { exitCode: 0 }; + }; +} + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly dockerMissing?: boolean; + readonly failSpawnFor?: (args: ReadonlyArray) => boolean; + readonly configuredProjectId?: string; + readonly skipConfig?: boolean; +} + +function setup(opts: SetupOpts = {}) { + const workdir = tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configuredProjectId ?? "demo"); + } + const out = mockOutput({ + format: opts.format ?? "text", + interactive: (opts.format ?? "text") === "text", + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); + const child = mockRoutedContainerCliSpawner(opts.route ?? defaultRoute(), { + dockerMissing: opts.dockerMissing, + failSpawnFor: opts.failSpawnFor, }); - return { layer, calls }; + + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + cliConfig, + telemetry.layer, + child.layer, + ); + + return { workdir, out, telemetry, child, layer }; } -const baseFlags: LegacyStopFlags = { - projectId: Option.none(), - backup: true, - noBackup: false, - all: false, -}; +describe("legacy stop integration", () => { + it.live( + "stops the current project's containers with backup and suggests the volume command", + () => { + const { layer, out, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ containerIds: ["c1", "c2"], volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + const stopCalls = child.spawned.filter((s) => s.args[0] === "stop"); + expect(stopCalls.map((s) => s.args)).toEqual([ + ["stop", "c1"], + ["stop", "c2"], + ]); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stdoutText).toContain("local development setup."); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain( + "docker volume ls --filter label=com.supabase.cli.project=demo", + ); + }).pipe(Effect.provide(layer)); + }, + ); -describe("legacy stop", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStop(); + it.live("stops every project's containers with --all without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(baseFlags); - expect(calls).toEqual([["stop"]]); + yield* legacyStop(flags({ all: true })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.ID}}", + ]); + const pruneCalls = child.spawned.filter( + (s) => s.args[0] === "container" && s.args[1] === "prune", + ); + expect(pruneCalls[0]?.args).toEqual([ + "container", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project", + ]); }).pipe(Effect.provide(layer)); }); - it.live("forwards --backup=false when the hidden --backup flag is disabled", () => { - const { layer, calls } = setupLegacyStop(); + it.live("suggests the bare-label volume command with --all when volumes remain", () => { + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); return Effect.gen(function* () { - yield* legacyStop({ ...baseFlags, backup: false }); - expect(calls).toEqual([["stop", "--backup=false"]]); + yield* legacyStop(flags({ all: true })); + expect(out.stderrText).toContain( + "Local data are backed up to docker volume. Use docker to show them:", + ); + expect(out.stderrText).toContain("docker volume ls --filter label=com.supabase.cli.project"); + expect(out.stderrText).not.toContain("com.supabase.cli.project="); }).pipe(Effect.provide(layer)); }); - it.live("forwards --no-backup, --project-id and --all", () => { - const { layer, calls } = setupLegacyStop(); + it.live("stops a named project with --project-id without reading config.toml", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop({ - projectId: Option.some("abc"), - backup: true, - noBackup: true, - all: true, - }); - expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + yield* legacyStop(flags({ projectId: Option.some("other-project") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=other-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-id together with --all", () => { + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyStop(flags({ projectId: Option.some("other-project"), all: true })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("deletes data volumes with --no-backup", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { + // Go's `--backup` is declared but never bound to a variable (`cmd/stop.go:26`) — + // `RunE` always passes `!noBackup`, so `--backup=false` has zero effect in the + // real Go binary today. Only `--no-backup` deletes volumes. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: false })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("--no-backup still deletes data volumes even when --backup stays true", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ backup: true, noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--all", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps data volumes by default (no volume prune call)", () => { + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const volumePrune = child.spawned.find( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when config.toml is malformed", () => { + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when stopping a container errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when a container cannot be spawned to stop it at all", () => { + // Distinct from a spawned `docker stop` exiting non-zero (covered above) — + // this exercises the branch where docker AND podman both fail to spawn for + // the `stop ` argv specifically. + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { stdout: ["c1"] } : { exitCode: 0 }), + failSpawnFor: (args) => args[0] === "stop", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails cleanly in json mode without a text-mode spinner to dismiss", () => { + // No `output.task` handle exists outside text mode — this exercises that + // the failure path's `stopping?.fail() ?? Effect.void` no-ops correctly. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "container" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "volume" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "network" && args[1] === "prune") return { exitCode: 1 }; + return defaultRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the container list errors", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { exitCode: 1, stderr: ["daemon down"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopListError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to podman when docker is absent", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + // The failed `docker` attempt is recorded before the `podman` fallback fires + // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the + // successful call is the LAST matching record, not the first. + const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); + expect(psCalls.at(-1)?.command).toBe("podman"); + expect(psCalls.some((s) => s.command === "docker")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine result in json mode without spinner text", () => { + const { layer, out } = setup({ + format: "json", + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: ["supabase_db_demo"] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ project_id_filter: "demo", backup: true }); + expect(out.stdoutText).not.toContain("\x1b[?25l"); + // json mode has no volume-suggestion equivalent — only text mode emits it. + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("shows no volume suggestion when no volumes remain", () => { + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ volumeNames: [] }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stderrText).not.toContain("Local data are backed up"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring even on failure", () => { + const { layer, telemetry } = setup({ + configuredProjectId: "demo", + route: (args) => (args[0] === "ps" ? { exitCode: 1 } : { exitCode: 0 }), + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyStop(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when container prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "container" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when volume prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when network prune cannot spawn any container runtime", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "network" && args[1] === "prune", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("still reports success when the post-run volume listing fails", () => { + // The volume-suggestion check is best-effort (`Effect.orElseSucceed`): a + // failure listing volumes after a successful stop must not fail the command, + // matching Go's `if resp, err := ...; err == nil && ...` (stop.go:29) — a + // listing error there is silently ignored, not surfaced. + const { layer, out } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + failSpawnFor: (args) => args[0] === "volume" && args[1] === "ls", + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + expect(out.stdoutText).toContain("Stopped"); + expect(out.stderrText).not.toContain("Local data are backed up"); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-api-url.ts b/apps/cli/src/legacy/shared/legacy-api-url.ts new file mode 100644 index 0000000000..c48b73ac44 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-api-url.ts @@ -0,0 +1,26 @@ +/** + * Local API URL derivation, mirroring Go's `config.go:634-644` + `misc.go:298`: + * an explicit `api.external_url` wins, otherwise `://:` + * where the scheme follows `api.tls.enabled` and the port is `api.port`. + * Hoisted here because `legacy-storage-credentials.ts` and + * `legacy-local-config-values.ts` both need this exact computation. + */ +export function legacyResolveApiExternalUrl( + config: { + readonly external_url?: string; + readonly port: number; + readonly tls: { readonly enabled: boolean }; + }, + hostname: string, +): string { + if (config.external_url !== undefined && config.external_url.length > 0) { + return config.external_url; + } + const scheme = config.tls.enabled ? "https" : "http"; + // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an + // IPv6 host. + const hostPort = hostname.includes(":") + ? `[${hostname}]:${config.port}` + : `${hostname}:${config.port}`; + return `${scheme}://${hostPort}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-colors.ts b/apps/cli/src/legacy/shared/legacy-colors.ts index c41cfae7d4..25d32d7c42 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.ts @@ -6,27 +6,38 @@ import { styleText } from "node:util"; * Go uses lipgloss, which auto-detects the output profile and renders **plain** * text when the stream is not a TTY (piped output, CI, tests). `styleText` * mirrors that: with `validateStream` (the default) it checks the target stream - * and `NO_COLOR`, returning the unstyled string when colour is unsupported. We - * point it at `process.stderr` because the bootstrap progress / suggestion lines - * these style are written to stderr. + * and `NO_COLOR`, returning the unstyled string when colour is unsupported. + * + * `stream` defaults to `process.stderr` because every original call site styles + * progress/suggestion lines written to stderr. A caller styling content that is + * itself written to **stdout** (e.g. `status`'s pretty table) must pass + * `process.stdout` explicitly — otherwise the TTY check runs against the wrong + * stream, and piping stdout while stderr stays a TTY (`supabase status | less`) + * would corrupt the piped output with ANSI escapes (the same bug class CLI-1546 + * fixed for the progress spinner). * * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. */ -export function legacyAqua(text: string): string { - return styleText("cyan", text, { stream: process.stderr }); +export function legacyAqua(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("cyan", text, { stream }); } -export function legacyBold(text: string): string { - return styleText("bold", text, { stream: process.stderr }); +export function legacyBold(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("bold", text, { stream }); } /** Port of Go's `utils.Yellow` — lipgloss colour "11" (bright yellow). */ -export function legacyYellow(text: string): string { - return styleText("yellow", text, { stream: process.stderr }); +export function legacyYellow(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("yellow", text, { stream }); } /** Port of Go's `utils.Red` — lipgloss colour "9" (bright red). */ -export function legacyRed(text: string): string { - return styleText("red", text, { stream: process.stderr }); +export function legacyRed(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("red", text, { stream }); +} + +/** Port of Go's `utils.Green` — lipgloss colour "10" (bright green). */ +export function legacyGreen(text: string, stream: NodeJS.WriteStream = process.stderr): string { + return styleText("green", text, { stream }); } diff --git a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts new file mode 100644 index 0000000000..e1f5e7873b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { legacyAqua, legacyBold, legacyGreen, legacyRed, legacyYellow } from "./legacy-colors.ts"; + +// These tests only assert that each helper runs without throwing and returns a +// string containing the input text — actual color application depends on the +// stream's live TTY/NO_COLOR state, which isn't controllable from a test +// process. The behavior worth protecting here is the `stream` parameter +// threading through to `styleText`, not a specific ANSI byte sequence. +describe("legacy-colors", () => { + it("legacyAqua defaults to stderr when no stream is given", () => { + expect(legacyAqua("supabase")).toContain("supabase"); + }); + + it("legacyAqua accepts an explicit stream", () => { + expect(legacyAqua("supabase", process.stdout)).toContain("supabase"); + }); + + it("legacyBold defaults to stderr when no stream is given", () => { + expect(legacyBold("text")).toContain("text"); + }); + + it("legacyBold accepts an explicit stream", () => { + expect(legacyBold("text", process.stdout)).toContain("text"); + }); + + it("legacyYellow defaults to stderr when no stream is given", () => { + expect(legacyYellow("warning")).toContain("warning"); + }); + + it("legacyYellow accepts an explicit stream", () => { + expect(legacyYellow("warning", process.stdout)).toContain("warning"); + }); + + it("legacyRed defaults to stderr when no stream is given", () => { + expect(legacyRed("error")).toContain("error"); + }); + + it("legacyRed accepts an explicit stream", () => { + expect(legacyRed("error", process.stdout)).toContain("error"); + }); + + it("legacyGreen defaults to stderr when no stream is given", () => { + expect(legacyGreen("label")).toContain("label"); + }); + + it("legacyGreen accepts an explicit stream", () => { + expect(legacyGreen("label", process.stdout)).toContain("label"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 3bf5f4244f..20a7a20654 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Data, Effect } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -14,6 +14,36 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner type Spawner = ChildProcessSpawner["Service"]; +/** + * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither + * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. + * Not exported: callers never need to match on this type directly, they fold it + * into their own tagged error via {@link legacyDescribeContainerCliFailure} so + * the "no runtime found" root cause survives instead of collapsing into a + * generic "failed to ..." message. + */ +class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( + "LegacyContainerRuntimeNotFoundError", +)<{ + readonly message: string; +}> {} + +const RUNTIME_NOT_FOUND_MESSAGE = + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; + +/** + * Renders a caller-facing suffix for a `spawnContainerCli`/`containerCliExitCode` + * failure: the clear "neither runtime found" message when that's the cause, + * otherwise the underlying cause's own message (falling back to `String(cause)` + * for non-`Error` causes) so callers never collapse a real failure reason into a + * bare, uninformative "failed to ..." string. + */ +export function legacyDescribeContainerCliFailure(cause: unknown): string { + if (cause instanceof LegacyContainerRuntimeNotFoundError) return cause.message; + if (cause instanceof Error) return cause.message; + return String(cause); +} + /** * Spawn a container-CLI command and return the process handle. Use when the * caller needs to read stdout/stderr or await the exit code itself. @@ -25,7 +55,19 @@ export const spawnContainerCli = ( ) => spawner .spawn(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.spawn(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .spawn(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); /** * Run a container-CLI command and resolve to its exit code, mirroring the @@ -38,4 +80,16 @@ export const containerCliExitCode = ( ) => spawner .exitCode(ChildProcess.make("docker", args, options)) - .pipe(Effect.catch(() => spawner.exitCode(ChildProcess.make("podman", args, options)))); + .pipe( + Effect.catch(() => + spawner + .exitCode(ChildProcess.make("podman", args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index 7c6f97aeca..e10297b8e2 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -2,9 +2,19 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { containerCliExitCode, spawnContainerCli } from "./legacy-container-cli.ts"; +import { + containerCliExitCode, + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "./legacy-container-cli.ts"; -function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode?: number } = {}) { +function mockSpawner( + opts: { + readonly dockerMissing?: boolean; + readonly bothMissing?: boolean; + readonly exitCode?: number; + } = {}, +) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -13,13 +23,13 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ command: cmd, args }); - if (opts.dockerMissing && cmd === "docker") { + if ((opts.dockerMissing && cmd === "docker") || opts.bothMissing === true) { return yield* Effect.fail( PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn", - description: "docker not found", + description: `${cmd} not found`, }), ); } @@ -98,4 +108,37 @@ describe("containerCliExitCode", () => { }), ); }); + + it.live("fails with a clear message when neither docker nor podman can be spawned", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["image", "inspect", "img"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toBe( + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH", + ); + }), + ); + }); +}); + +describe("legacyDescribeContainerCliFailure", () => { + it.live("describes a both-runtimes-missing failure with its clear message", () => { + const mock = mockSpawner({ bothMissing: true }); + return containerCliExitCode(mock.spawner, ["ps"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(legacyDescribeContainerCliFailure(error)).toContain("docker: command not found"); + }), + ); + }); + + it("falls back to an Error instance's own message", () => { + expect(legacyDescribeContainerCliFailure(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyDescribeContainerCliFailure("boom")).toBe("boom"); + expect(legacyDescribeContainerCliFailure(42)).toBe("42"); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 41a5e74b14..540029737b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -52,3 +52,50 @@ export function localDbContainerId(projectId: string) { export function localNetworkId(projectId: string) { return localDockerId("network", projectId); } + +/** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the + * Docker label every container/volume/network created by `supabase start` carries. */ +export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; + +/** + * Go's `utils.GetDockerIds()` (`apps/cli-go/internal/utils/config.go:82-98`) — the + * 13 service container ids (excludes `db`, `network`, and the `differ` shadow + * container, which are not part of the "expected running services" set). Order and + * alias-name strings are taken verbatim from `config.go:36-49,61-79`. + */ +export function legacyServiceContainerIds(projectId: string): ReadonlyArray { + return [ + localDockerId("kong", projectId), + localDockerId("auth", projectId), + localDockerId("inbucket", projectId), + localDockerId("realtime", projectId), + localDockerId("rest", projectId), + localDockerId("storage", projectId), + localDockerId("imgproxy", projectId), + localDockerId("pg_meta", projectId), + localDockerId("studio", projectId), + localDockerId("edge_runtime", projectId), + localDockerId("analytics", projectId), + localDockerId("vector", projectId), + localDockerId("pooler", projectId), + ]; +} + +/** + * Go's `utils.CliProjectFilter` (`apps/cli-go/internal/utils/docker.go:148-156`) — + * the value that follows `--filter label=` on the `docker`/`podman` CLI. An empty + * `projectId` (Go's `--all` path) filters on the bare label across every project. + * + * Deliberately unsanitized, matching Go exactly: `CliProjectFilter` interpolates + * `projectId` into the filter string raw, with none of `GetId`'s (this file's + * `sanitizeProjectId`) character-stripping — that sanitization only applies to + * Go's own generated container *names*, never to a user-supplied `--project-id` + * filter value. There is no injection risk from skipping it here: this value is + * always passed as a single argv element to a spawned process (never through a + * shell), so a malformed value can only make Docker's own filter parsing reject + * it or match nothing — it cannot break out into another command. + */ +export function legacyCliProjectFilterValue(projectId: string): string { + if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; + return `${LEGACY_CLI_PROJECT_LABEL}=${projectId}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ff967f18b8..ba33e89607 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { legacyResolveLocalProjectId, localDbContainerId } from "./legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -23,3 +29,40 @@ describe("legacyResolveLocalProjectId", () => { expect(localDbContainerId(id)).toBe("supabase_db_env-id"); }); }); + +describe("legacyServiceContainerIds", () => { + it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + // apps/cli-go/internal/utils/config.go:82-98 — kong, auth, inbucket, realtime, + // rest, storage, imgproxy, pg_meta, studio, edge_runtime, analytics, vector, pooler. + expect(legacyServiceContainerIds("my-app")).toEqual([ + "supabase_kong_my-app", + "supabase_auth_my-app", + "supabase_inbucket_my-app", + "supabase_realtime_my-app", + "supabase_rest_my-app", + "supabase_storage_my-app", + "supabase_imgproxy_my-app", + "supabase_pg_meta_my-app", + "supabase_studio_my-app", + "supabase_edge_runtime_my-app", + "supabase_analytics_my-app", + "supabase_vector_my-app", + "supabase_pooler_my-app", + ]); + }); + + it("sanitizes the project id the same way as localDbContainerId", () => { + const ids = legacyServiceContainerIds("My App!!"); + expect(ids[0]).toBe("supabase_kong_My_App_"); + }); +}); + +describe("legacyCliProjectFilterValue", () => { + it("returns the bare label when the project id is empty (Go's --all path)", () => { + expect(legacyCliProjectFilterValue("")).toBe(LEGACY_CLI_PROJECT_LABEL); + }); + + it("returns label=projectId when a project id is given", () => { + expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts new file mode 100644 index 0000000000..2a89464e10 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -0,0 +1,240 @@ +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Listing containers or volumes by Docker label failed. Wraps Go's + * `Docker.ContainerList`/`Docker.VolumeList` errors (`docker.go:99-104`, + * `docker.go:334-336` — see `checkServiceHealth`/`DockerRemoveAll`), which Go + * wraps as `"failed to list containers: %w"` / equivalent. + */ +export class LegacyDockerLifecycleListError extends Data.TaggedError( + "LegacyDockerLifecycleListError", +)<{ + readonly message: string; +}> {} + +/** Inspecting a single container's state failed for a reason other than "not found". */ +export class LegacyDockerLifecycleInspectError extends Data.TaggedError( + "LegacyDockerLifecycleInspectError", +)<{ + readonly message: string; +}> {} + +function collectByteStream(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +function splitNonEmptyLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function isMissingContainerError(stderr: string): boolean { + return stderr.toLowerCase().includes("no such container"); +} + +/** + * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` + * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter + * label=`. `all: false` mirrors `status`'s running-only list; + * `all: true` mirrors `stop`'s "every container regardless of state" list. + */ +export const legacyListContainersByLabel = ( + spawner: Spawner, + opts: { + readonly projectIdFilter: string; + readonly all: boolean; + readonly format: "id" | "names"; + }, +) => + Effect.scoped( + Effect.gen(function* () { + const formatArg = opts.format === "names" ? "{{.Names}}" : "{{.ID}}"; + const args = [ + "ps", + "--filter", + `label=${opts.projectIdFilter}`, + ...(opts.all ? ["--all"] : []), + "--format", + formatArg, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list containers: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 + ? `failed to list containers: ${message}` + : "failed to list containers", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); + +/** + * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, + * `status.go:148-155`) via `docker container inspect --format + * {{json .State}}`. A "no such container" stderr resolves to the literal + * `"absent"`, mirroring `errdefs.IsNotFound(err)` — every other non-zero exit + * propagates as `LegacyDockerLifecycleInspectError`, matching Go's + * `assertContainerHealthy`, which does not special-case any other inspect failure. + */ +export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["container", "inspect", containerId, "--format", "{{json .State}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => + new LegacyDockerLifecycleInspectError({ + message: "failed to inspect container health", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + if (isMissingContainerError(message)) { + return "absent" as const; + } + return yield* Effect.fail( + new LegacyDockerLifecycleInspectError({ + message: + message.length > 0 + ? `failed to inspect container health: ${message}` + : "failed to inspect container health", + }), + ); + } + return parseContainerState(stdout); + }), + ); + +function parseContainerState(stdout: string): { + readonly running: boolean; + readonly status: string; + readonly health?: string; +} { + const trimmed = stdout.trim(); + let parsed: unknown; + try { + parsed = trimmed.length > 0 ? JSON.parse(trimmed) : {}; + } catch { + parsed = {}; + } + const state = isJsonRecord(parsed) ? parsed : {}; + const status = typeof state["Status"] === "string" ? state["Status"] : ""; + const running = status === "running"; + const health = state["Health"]; + const healthStatus = + isJsonRecord(health) && typeof health["Status"] === "string" ? health["Status"] : undefined; + return healthStatus !== undefined + ? { running, status, health: healthStatus } + : { running, status }; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} + +/** + * Go's `Docker.VolumeList(ctx, volume.ListOptions{Filters})` + * (`docker.go` — used by the `stop` post-run volume-suggestion check) via + * `docker volume ls --filter label=`. + */ +export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => + Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "ls", + "--filter", + `label=${projectIdFilter}`, + "--format", + "{{.Name}}", + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyDockerLifecycleListError({ + message: `failed to list volumes: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all([ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ]).pipe( + Effect.mapError( + () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyDockerLifecycleListError({ + message: + message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", + }), + ); + } + return splitNonEmptyLines(stdout); + }), + ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts new file mode 100644 index 0000000000..29704048c4 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyDockerLifecycleInspectError, + LegacyDockerLifecycleListError, + legacyInspectContainerState, + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "./legacy-docker-lifecycle.ts"; + +function mockSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + } = {}, +) { + const encoder = new TextEncoder(); + const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), + stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyListContainersByLabel", () => { + it.live("returns container ids for a successful listing", () => { + const mock = mockSpawner({ stdout: "abc123\ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=my-app", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.ID}}", + ], + }, + ]); + }), + ); + }); + + it.live("passes --all and requests names when configured", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "names", + }).pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project", + "--all", + "--format", + "{{.Names}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no containers match", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: true, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual([]); + }), + ); + }); + + it.live("filters out blank lines from the trimmed output", () => { + const mock = mockSpawner({ stdout: "abc123\n\n \ndef456\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.map((ids) => { + expect(ids).toEqual(["abc123", "def456"]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe( + "failed to list containers: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListContainersByLabel(mock.spawner, { + projectIdFilter: "com.supabase.cli.project", + all: false, + format: "id", + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list containers"); + }), + ); + }); +}); + +describe("legacyInspectContainerState", () => { + it.live("parses a running, healthy container's state", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ Status: "running", Health: { Status: "healthy" } }), + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running", health: "healthy" }); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["container", "inspect", "supabase_db_my-app", "--format", "{{json .State}}"], + }, + ]); + }), + ); + }); + + it.live("parses a running container with no health check configured", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "running" }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "running" }); + }), + ); + }); + + it.live("parses a stopped/exited container", () => { + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "exited" }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "exited" }); + }), + ); + }); + + it.live('resolves to "absent" when the container does not exist', () => { + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toBe("absent"); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Cannot connect to the Docker daemon", + ); + }), + ); + }); + + it.live( + "fails with LegacyDockerLifecycleInspectError with a generic message when stderr is empty", + () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe("failed to inspect container health"); + }), + ); + }, + ); + + it.live("treats empty inspect output as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); + + it.live("treats non-object inspect JSON as an unknown, not-running state", () => { + const mock = mockSpawner({ stdout: "null" }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "" }); + }), + ); + }); +}); + +describe("legacyListVolumesByLabel", () => { + it.live("returns volume names for a successful listing", () => { + const mock = mockSpawner({ stdout: "supabase_db_my-app\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project=my-app").pipe( + Effect.map((names) => { + expect(names).toEqual(["supabase_db_my-app"]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "volume", + "ls", + "--filter", + "label=com.supabase.cli.project=my-app", + "--format", + "{{.Name}}", + ], + }, + ]); + }), + ); + }); + + it.live("returns an empty array when no volumes remain", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.map((names) => { + expect(names).toEqual([]); + }), + ); + }); + + it.live("fails with LegacyDockerLifecycleListError on a non-zero exit", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "boom\n" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes: boom"); + }), + ); + }); + + it.live("fails with a generic message when stderr is empty", () => { + const mock = mockSpawner({ exitCode: 1, stderr: "" }); + return legacyListVolumesByLabel(mock.spawner, "com.supabase.cli.project").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleListError); + expect(error.message).toBe("failed to list volumes"); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts new file mode 100644 index 0000000000..7c6c75d178 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -0,0 +1,38 @@ +import { createHmac } from "node:crypto"; + +/** + * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` + * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * + * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` + * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, + * a dynamic `iat`/10-year `exp`, and a different claim order, none of which + * byte-match what Go prints for `supabase status`. Go's claims, in + * declaration order (the outer `CustomClaims.Issuer` field shadows the + * embedded `jwt.RegisteredClaims.Issuer`, so only one `iss` key is emitted): + * + * iss (fixed "supabase-demo"), ref (omitempty), role, is_anonymous (omitempty), + * then the remaining `jwt.RegisteredClaims` fields (sub, aud, exp, nbf, iat, jti), + * all `omitempty` except `exp`, which Go always sets to the fixed + * `defaultJwtExpiry = 1983812996` unix timestamp (never computed from "now"). + * + * `status` never sets `ref`/`is_anonymous`, so for this signer's two roles the + * payload always serializes to exactly `{"iss":...,"role":...,"exp":...}`. + */ + +const GO_JWT_ISSUER = "supabase-demo"; +const GO_JWT_FIXED_EXP = 1983812996; + +function base64UrlEncode(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role"): string { + const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + ); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts new file mode 100644 index 0000000000..3c8aa39b84 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -0,0 +1,65 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; + +const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; + +function decodeSegment(segment: string): string { + return Buffer.from(segment, "base64url").toString("utf8"); +} + +describe("legacyGenerateGoJwt", () => { + it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header] = token.split("."); + expect(header).toBeDefined(); + // Go's jwt.NewWithClaims builds Header as map[string]any{"typ":..,"alg":..}; + // encoding/json marshals map keys in sorted order, so "alg" sorts before "typ". + expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); + }); + + it("emits the anon payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [, payload] = token.split("."); + expect(payload).toBeDefined(); + const raw = decodeSegment(payload ?? ""); + // Byte-exact key order: iss, role, exp — ref/is_anonymous/iat are omitted + // entirely (Go's `omitempty`), matching status's no-ref, non-anonymous use. + expect(raw).toBe('{"iss":"supabase-demo","role":"anon","exp":1983812996}'); + + const parsed = JSON.parse(raw) as Record; + expect(parsed).toEqual({ iss: "supabase-demo", role: "anon", exp: 1983812996 }); + expect(Object.keys(parsed)).not.toContain("iat"); + expect(Object.keys(parsed)).not.toContain("ref"); + expect(Object.keys(parsed)).not.toContain("is_anonymous"); + }); + + it("emits the service_role payload with Go's exact key order and fixed claims", () => { + const token = legacyGenerateGoJwt(SECRET, "service_role"); + const [, payload] = token.split("."); + const raw = decodeSegment(payload ?? ""); + expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); + }); + + it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { + const token = legacyGenerateGoJwt(SECRET, "anon"); + const [header, payload, signature] = token.split("."); + const expectedSignature = createHmac("sha256", SECRET) + .update(`${header}.${payload}`) + .digest("base64url"); + expect(signature).toBe(expectedSignature); + }); + + it("is deterministic across calls (no timestamp derived from Date.now())", () => { + const first = legacyGenerateGoJwt(SECRET, "anon"); + const second = legacyGenerateGoJwt(SECRET, "anon"); + expect(first).toBe(second); + }); + + it("produces different tokens for different secrets", () => { + const a = legacyGenerateGoJwt(SECRET, "anon"); + const b = legacyGenerateGoJwt("a-different-secret-value-1234567", "anon"); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts new file mode 100644 index 0000000000..2c6b4fac51 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -0,0 +1,111 @@ +import type { ProjectConfig } from "@supabase/config"; +import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; + +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; + +/** + * Go-parity derived local-dev config values, ported from `utils.Config`'s + * post-load defaulting (`pkg/config/config.go:406-441,748-758`) and + * `utils.GetApiUrl`/status's `toValues()` (`internal/utils/config.go:255-268`, + * `internal/status/status.go:52-95`). `@supabase/config`'s schema has no field for + * a handful of Go constants (`db.password`, the S3 credential triple) — those are + * Go-hardcoded literals, reproduced here rather than added to the shared schema + * (`pkg/config/config.go:408,437-441`). + * + * Kept generic (no `status`-specific shaping) so a future native `start`/`restart` + * port can reuse it instead of re-deriving these values — see the plan's + * "Files to create" note. Do not fold this into `legacy-storage-credentials.ts`; + * that module resolves credentials through a different (HTTP/tenant-aware) path + * for the remote-project branch, which this pure resolver does not need (the + * shared `://:` derivation itself lives in + * `legacy-api-url.ts`, used by both). + */ + +/** Go's `Db.Password` default (`pkg/config/config.go:408`) — never present in config.toml. */ +const DEFAULT_DB_PASSWORD = "postgres"; + +/** Go's hardcoded local S3 credentials (`pkg/config/config.go:437-441`). */ +const DEFAULT_S3_ACCESS_KEY_ID = "625729a08b95bf1b7ff351a663f3a23c"; +const DEFAULT_S3_SECRET_ACCESS_KEY = + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"; +const DEFAULT_S3_REGION = "local"; + +export interface LegacyLocalConfigValues { + readonly apiUrl: string; + readonly restUrl: string; + readonly graphqlUrl: string; + readonly functionsUrl: string; + readonly mcpUrl: string; + readonly studioUrl: string; + readonly mailpitUrl: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly jwtSecret: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageS3Url: string; + readonly storageS3AccessKeyId: string; + readonly storageS3SecretAccessKey: string; + readonly storageS3Region: string; +} + +/** + * Go's `utils.GetApiUrl(path)` (`internal/utils/config.go:255-268`): appends + * `path` to the resolved external URL. Go's own fallback branch (building a bare + * `http://host:port` when `Config.Api.ExternalUrl` is empty) is unreachable in + * practice because `config.Load` already defaults `ExternalUrl` before `status` + * runs — `resolveApiExternalUrl` reproduces that same default, so `apiExternalUrl` + * passed in here is never empty. + */ +function apiUrlWithPath(apiExternalUrl: string, path: string): string { + return `${apiExternalUrl}${path}`; +} + +/** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ +function resolveJwtSecret(configured: string | undefined): string { + return configured !== undefined && configured.length > 0 ? configured : defaultJwtSecret; +} + +function resolveOpaqueKey(configured: string | undefined, fallback: string): string { + return configured !== undefined && configured.length > 0 ? configured : fallback; +} + +function resolveSignedKey( + configured: string | undefined, + jwtSecret: string, + role: "anon" | "service_role", +): string { + return configured !== undefined && configured.length > 0 + ? configured + : legacyGenerateGoJwt(jwtSecret, role); +} + +export function legacyResolveLocalConfigValues( + config: ProjectConfig, + hostname: string, +): LegacyLocalConfigValues { + const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); + const jwtSecret = resolveJwtSecret(config.auth.jwt_secret); + + return { + apiUrl: apiExternalUrl, + restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), + graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), + functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), + mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), + studioUrl: `http://${hostname}:${config.studio.port}`, + mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, + publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), + secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), + jwtSecret, + anonKey: resolveSignedKey(config.auth.anon_key, jwtSecret, "anon"), + serviceRoleKey: resolveSignedKey(config.auth.service_role_key, jwtSecret, "service_role"), + storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), + storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, + storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, + storageS3Region: DEFAULT_S3_REGION, + }; +} diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts new file mode 100644 index 0000000000..0abfb35a57 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -0,0 +1,110 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +describe("legacyResolveLocalConfigValues", () => { + it("derives every URL from api.external_url when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); + expect(values.graphqlUrl).toBe("http://127.0.0.1:54321/graphql/v1"); + expect(values.functionsUrl).toBe("http://127.0.0.1:54321/functions/v1"); + expect(values.mcpUrl).toBe("http://127.0.0.1:54321/mcp"); + expect(values.storageS3Url).toBe("http://127.0.0.1:54321/storage/v1/s3"); + expect(values.studioUrl).toBe("http://127.0.0.1:54323"); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54324"); + }); + + it("uses https and the configured port when api.tls.enabled", () => { + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("uses api.external_url verbatim when configured", () => { + const config = baseConfig({ api: { external_url: "https://example.test" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.apiUrl).toBe("https://example.test"); + expect(values.restUrl).toBe("https://example.test/rest/v1"); + }); + + it("brackets an IPv6 hostname when building host:port", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "::1"); + expect(values.apiUrl).toBe("http://[::1]:54321"); + }); + + it("builds the db URL with the hardcoded postgres password", () => { + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + it("falls back to the default JWT secret and opaque keys when unset", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); + expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); + expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); + }); + + it("uses configured opaque keys verbatim when set", () => { + const config = baseConfig({ + auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.publishableKey).toBe("sb_publishable_custom"); + expect(values.secretKey).toBe("sb_secret_custom"); + }); + + it("signs the default anon/service_role JWTs from the resolved secret", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we + // only assert the resolver wires the default secret through to both roles. + const [, anonPayload] = values.anonKey.split("."); + const [, serviceRolePayload] = values.serviceRoleKey.split("."); + expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ + role: "anon", + }); + expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( + { role: "service_role" }, + ); + }); + + it("uses configured anon/service_role keys verbatim when set", () => { + const config = baseConfig({ + auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("signs anon/service_role JWTs from a configured jwt_secret", () => { + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.jwtSecret).toBe("a".repeat(32)); + expect(values.anonKey).not.toBe(""); + }); + + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + expect(values.storageS3Region).toBe("local"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 0826a7de8f..21983e8d15 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; +import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; @@ -122,23 +123,11 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts }); /** - * Local API URL, mirroring Go's `config.go:634-644` + `misc.go:298`: an explicit - * `api.external_url` wins, otherwise `://:` where the scheme - * follows `api.tls.enabled`, the host is `legacyGetHostname` (Go's - * `utils.GetHostname`), and the port is `api.port`. + * Local API URL: `legacyResolveApiExternalUrl` with `legacyGetHostname` (Go's + * `utils.GetHostname`) supplying the host when `api.external_url` is unset. */ function resolveLocalBaseUrl(config: LegacyStorageConfigView): string { - if (config.api.external_url !== undefined && config.api.external_url.length > 0) { - return config.api.external_url; - } - const host = legacyGetHostname(); - const scheme = config.api.tls.enabled ? "https" : "http"; - // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an - // IPv6 host. legacyGetHostname returns the unbracketed host, so bracket here. - const hostPort = host.includes(":") - ? `[${host}]:${config.api.port}` - : `${host}:${config.api.port}`; - return `${scheme}://${hostPort}`; + return legacyResolveApiExternalUrl(config.api, legacyGetHostname()); } /** diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 8dc2efe194..3020fe3c1d 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -123,10 +123,15 @@ describe("native hidden flags", () => { Effect.scoped( Effect.gen(function* () { yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["start", "--preview"]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + // `stop` is natively ported (no longer a `LegacyGoProxy` forward), so it can fail for + // Docker-related reasons in this proxy-only test layer — the point here is only to + // prove the hidden `--backup` flag still parses by exact name, not that the command + // succeeds, matching the `functions deploy`/`serve` assertions below. + const stopExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "stop", "--backup=false", - ]); + ]).pipe(Effect.exit); + expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", @@ -162,7 +167,6 @@ describe("native hidden flags", () => { expect(proxy.calls).toEqual([ ["start", "--preview"], - ["stop", "--backup=false"], ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], ]); }); From 491f0529fe727c7ee9b6bc4294476d28dcc34f35 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 11:12:10 +0100 Subject: [PATCH 02/22] test(cli): add live tests for supabase stop/status and document *.live.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the *.live.test.ts convention in AGENTS.md (a 4th test category alongside unit/integration/e2e): black-box CLI subprocess tests executed by the cli-e2e-ci harness against a real supabox stack. Clarifies how local-dev-stack commands like stop/status fit this pattern despite never calling the Management API — they only need the real Docker daemon the cli-e2e-ci runner also provides, so they reuse the existing describeLive gate rather than a dedicated one. Adds stop.live.test.ts and status.live.test.ts, each spinning up a real local Docker stack (init -> start, excluding the heaviest services) in an isolated temp directory and verifying the command under test against the real containers, with best-effort cleanup on every path. --- apps/cli/AGENTS.md | 16 ++++ .../commands/status/status.live.test.ts | 54 +++++++++++++ .../legacy/commands/stop/stop.live.test.ts | 78 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 apps/cli/src/legacy/commands/status/status.live.test.ts create mode 100644 apps/cli/src/legacy/commands/stop/stop.live.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c30465cd85..f16021bf07 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -402,6 +402,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - `*.unit.test.ts` belongs to the `unit` Vitest project and is the default for unit-style and other fast in-process tests. - `*.integration.test.ts` belongs to the `integration` project and is for in-process integration tests that exercise real handler or service behavior with layered dependency replacement. - `*.e2e.test.ts` belongs to the `e2e` Vitest project and is for black-box CLI subprocess tests. +- `*.live.test.ts` belongs to the `live` Vitest project and is for black-box CLI subprocess tests that run against a **real, running Supabase platform or local Docker stack** — see "Live tests" below. ### Testing policy @@ -416,6 +417,21 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. +### Live tests (`*.live.test.ts`) + +Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run against a **real backend** instead of local fakes/mocks: either the real Management API (a full [supabox](https://github.com/supabase/supabox) platform stack) or a real local Docker dev stack (`supabase start`'s actual containers). They are the highest-fidelity, most expensive tier — reserved for the small set of behaviors that only a genuinely running backend can prove (auth round-trips, real Docker label filtering, real container lifecycle), not for anything an integration test can already cover with mocks. + +- **Where they run:** authored in this repo, but executed by the [`supabase/cli-e2e-ci`](https://github.com/supabase/cli-e2e-ci) harness, which builds this CLI, brings up a full supabox stack (and has a real Docker daemon, since that's how supabox itself runs), and invokes the `live` Vitest project (`nx run-many -t test:live`). They never run as part of the default unit/integration/e2e loop, and locally they no-op unless the live environment is configured (see below) — there is no need to stand up supabox yourself to develop other code. +- **Add one whenever you add or change a command whose correctness genuinely depends on a real backend** — a new Management API command, or a change to `start`/`stop`/`status`'s real Docker interaction. Colocate it with the command, same as `*.e2e.test.ts`: `src/legacy/commands//[/].live.test.ts`. +- **Gating:** every live suite must be wrapped in one of `tests/helpers/live.ts`'s `describe.skipIf` gates so the file is inert (skipped, not failed) outside the cli-e2e-ci runner: + - `describeLive` — runs whenever `SUPABASE_ACCESS_TOKEN` is set (the live env is configured at all). Reuse this even for commands that don't call the Management API themselves (e.g. `stop`/`status`) — it doubles as the "we're in the full cli-e2e-ci runner, which also has a real Docker daemon" signal, and there is no dedicated Docker-availability gate today. + - `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db). + - `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage). +- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing. +- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example. +- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`. +- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget). + --- ## Go CLI Parity Tracking diff --git a/apps/cli/src/legacy/commands/status/status.live.test.ts b/apps/cli/src/legacy/commands/status/status.live.test.ts new file mode 100644 index 0000000000..64569c118c --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.live.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const START_TIMEOUT_MS = 280_000; + +// See stop.live.test.ts for why `describeLive` (not a Management-API gate) is +// the right reuse here: `status` never calls the Management API, only the real +// Docker daemon the cli-e2e-ci runner provides. See AGENTS.md's "Live tests" +// section for the full convention. +describeLive("supabase status (live)", () => { + let projectDir: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "reports a running local stack in pretty and json modes", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-status-live-")); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const pretty = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(pretty.exitCode, `stdout:\n${pretty.stdout}\nstderr:\n${pretty.stderr}`).toBe(0); + expect(`${pretty.stdout}${pretty.stderr}`).toContain("is running"); + expect(pretty.stdout).toContain("Project URL"); + expect(pretty.stdout).toContain("Database"); + + const json = await runSupabaseLive(["status", "-o", "json"], { cwd: projectDir }); + expect(json.exitCode, `stdout:\n${json.stdout}\nstderr:\n${json.stderr}`).toBe(0); + const parsed: unknown = JSON.parse(json.stdout); + expect(parsed).toMatchObject({ + API_URL: expect.stringContaining("http"), + DB_URL: expect.stringContaining("postgresql://"), + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts new file mode 100644 index 0000000000..95452c78c7 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.live.test.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; + +import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; + +// `stop` never calls the Management API — it talks directly to the real local +// Docker stack `start` (still a Go-proxy) creates. `describeLive` is reused +// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a +// real Docker daemon, since that's how supabox itself runs); the +// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See +// AGENTS.md's "Live tests" section for the full convention. +describeLive("supabase stop (live)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "starts a real local stack, then stops it and removes its containers", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); + // No `project_id` override, so the cli resolves it from the workdir + // basename — matching Go's precedence exactly (see legacy-docker-ids.ts). + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services (Next.js Studio build, the + // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care + // which services are running, only that at least one real container + // exists to stop. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + // Sanity: confirm the stack is actually up before testing `stop` against it. + const before = await runSupabaseLive(["status"], { cwd: projectDir }); + expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); + + const stop = await runSupabaseLive(["stop"], { cwd: projectDir }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // The real Docker daemon must agree: no container carrying this project's + // label survives `stop` — the actual behavior under test, not just the + // cli's own exit code. + const { stdout: remaining } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ]); + expect(remaining.trim()).toBe(""); + }, + ); +}); From deaf1e66020c59caa8adc36d391c3a9e25a04983 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 12:13:37 +0100 Subject: [PATCH 03/22] fix(cli): address Go-parity review findings on stop/status (review: #5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accepted findings from PR review, each verified against Go source before implementing: - Sanitize the config/env-derived project id before building the Docker label filter in both `stop` and `status`. Go's `Config.Validate` sanitizes `Config.ProjectId` once at config-load time (pkg/config/config.go:938-944), and every later reader — including the Docker label `start` writes (internal/utils/docker.go:375) — sees that same sanitized string. Without this, a dirty `project_id` (spaces, leading punctuation) would filter on a value `start` never labeled, silently matching nothing. The explicit `--project-id` bypass on `stop` stays raw, matching Go's stop.go:19-20. - `status --override-name` with an unrecognized field key is now silently ignored instead of a hard error, matching Go's `go-env` Unmarshal, which never checks its input map for unmatched keys (verified against the vendored go-env@v0.1.2 source). - `status` no longer hard-fails when `supabase/config.toml` is absent; Go's `flags.LoadConfig` treats a missing file as a no-op and proceeds with template defaults (pkg/config/config.go:655-656), so this now decodes an empty document through the shared config schema for its defaults instead of erroring. (The broader gap — Go's automatic `SUPABASE_` env-var binding via viper, which `@supabase/config` doesn't have an equivalent for — is a larger, cross-cutting `@supabase/config` feature affecting every ported command, called out as a follow-up rather than folded into this fix.) Two other findings were investigated and rejected with cited evidence (directly on the PR): a claimed `--backup`/`--no-backup` flag collision (empirically disproven against Effect's parser), and image-name-based `--exclude` matching (the underlying config.toml schema has no field to check, on either CLI). --- .../legacy/commands/status/status.handler.ts | 58 ++++++++------ .../status/status.integration.test.ts | 78 +++++++++++++++---- .../src/legacy/commands/stop/stop.handler.ts | 14 +++- .../commands/stop/stop.integration.test.ts | 46 +++++++++++ .../src/legacy/shared/legacy-docker-ids.ts | 46 ++++++++--- .../shared/legacy-docker-ids.unit.test.ts | 33 ++++++++ 6 files changed, 223 insertions(+), 52 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 2444a07d54..21a4026add 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,6 +1,6 @@ -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, Option } from "effect"; +import { Effect, Option, Schema } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; @@ -10,6 +10,7 @@ import { legacyAqua } from "../../shared/legacy-colors.ts"; import { legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; @@ -44,7 +45,13 @@ import { * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a * `fieldKey -> outputName` map, mirroring Go's `env.EnvironToEnvSet` + * `env.Unmarshal` (`cmd/status.go:21-27`): each entry must be a `KEY=VALUE` - * pair whose `KEY` matches one of the 18 known `CustomName` field keys. + * pair. `env.EnvironToEnvSet` only validates that shape (`go-env`'s + * `ErrInvalidEnviron`); the Netflix `go-env` library's `Unmarshal` then walks + * `CustomName`'s own struct fields and looks up each field's tag in the + * resulting map — it never checks the map for leftover/unmatched keys, so an + * entry whose `KEY` isn't one of the 18 known `CustomName` field keys is + * silently ignored, not an error (verified against `go-env@v0.1.2`'s + * `env.go`/`transform.go`). */ function parseOverrides( entries: ReadonlyArray, @@ -63,11 +70,7 @@ function parseOverrides( const key = entry.slice(0, separatorIndex); const value = entry.slice(separatorIndex + 1); if (!knownKeys.has(key)) { - return Effect.fail( - new LegacyStatusOverrideParseError({ - message: `unknown override-name key: ${key}`, - }), - ); + continue; } overrides.set(key, value); } @@ -87,27 +90,36 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; yield* Effect.gen(function* () { - // 1. `status` always needs config, unlike `stop` (status.go:99-103). + // 1. `status` always needs config, unlike `stop` (status.go:99-103). An + // ABSENT config.toml is not a hard failure in Go: `flags.LoadConfig` -> + // `Config.Load` -> `loadFromFile` -> `mergeFileConfig` treats a missing + // file as a no-op (`os.ErrNotExist` -> nil, pkg/config/config.go:655-656) + // and proceeds with template defaults (`mergeDefaultValues`, + // pkg/config/config.go:639-648). Only a MALFORMED file is a hard error. + // Mirror that by decoding an empty document through the schema for its + // defaults (matching `packages/config/src/functions-manifest.ts`'s + // `decodeProjectConfig({})` pattern) instead of failing. const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( Effect.mapError( (cause) => new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - if (loaded === null) { - return yield* Effect.fail( - new LegacyStatusConfigLoadError({ - message: "failed to read config: supabase/config.toml not found", - }), - ); - } - const config = loaded.config; - - // 2. status has no --project-id flag; resolution is always env → toml → workdir basename. - const projectId = legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], - config.project_id, - cliConfig.workdir, + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // 2. status has no --project-id flag; resolution is always env → toml → + // workdir basename, then sanitized to match the singleton Go's + // `Config.Validate` produces once at config-load time + // (`pkg/config/config.go:938-944`) — every reader, including the Docker + // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same + // sanitized string, so `status` must filter on it too (see + // `legacyCliProjectFilterValue`'s doc comment). + const projectId = legacySanitizeProjectId( + legacyResolveLocalProjectId( + process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ), ); const dbContainerId = localDbContainerId(projectId); diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index ac39129679..28fa703817 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -208,6 +208,27 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering/inspecting with the raw value here would target + // containers `start` never created. + const { layer, child } = setup({ configContents: 'project_id = "My App!!"\n' }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args[2]).toBe(localDbContainerId("My_App_")); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=My_App_"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("skips the db health check with --ignore-health-check", () => { const { layer, child } = setup({ route: (args) => { @@ -251,14 +272,27 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when config.toml is missing entirely", () => { - const { layer } = setup({ skipConfig: true }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { + // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> + // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> + // nil, pkg/config/config.go:655-656), not an error — `status` proceeds + // using template defaults. Only a malformed file is a hard failure (see + // the sibling "malformed" test above). + // + // Without config.toml, the resolved project id falls back to the workdir + // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to + // "demo") — route `ps` off that so the expected services actually show as + // running rather than all appearing "stopped" and excluded. + const projectId = basename(tempRoot.current); + const { layer, out } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), + }); return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyStatus(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); - } + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("Project URL"); + expect(out.stdoutText).toContain("Database"); }).pipe(Effect.provide(layer)); }); @@ -434,16 +468,28 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails on an --override-name entry with an unknown field key", () => { - const { layer } = setup(); + it.live("silently ignores an --override-name entry with an unknown field key", () => { + // Matches Go: `env.Unmarshal` (Netflix go-env) walks CustomName's own struct + // fields and looks up each field's tag in the override map — it never checks + // for leftover/unmatched keys, so an unrecognized key is a no-op, not an error. + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + yield* legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NAME).toBeUndefined(); + expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); + }).pipe(Effect.provide(layer)); + }); + + it.live("applies a valid --override-name entry alongside an unknown one", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })), + yield* legacyStatus( + flags({ overrideName: ["not.a.real.field=NAME", "api.url=NEXT_PUBLIC_SUPABASE_URL"] }), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); - } + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); + expect(parsed.NAME).toBeUndefined(); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index a2e6863f0e..e6f5dcebfc 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -13,6 +13,7 @@ import { import { legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, } from "../../shared/legacy-docker-ids.ts"; import { legacyListContainersByLabel, @@ -35,6 +36,16 @@ import { * `--project-id` overrides `Config.ProjectId` directly, also bypassing * config.toml; otherwise `flags.LoadConfig` reads config.toml and * `Config.ProjectId` (env → toml → workdir basename) is used. + * + * The config/env-derived (default) branch is sanitized with + * {@link legacySanitizeProjectId} before it's used as a filter value, + * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` + * singleton once at config-load time (`pkg/config/config.go:938-944`) — every + * later reader, including the Docker LABEL `start` writes + * (`internal/utils/docker.go:375`), sees that same sanitized string. The + * explicit `--project-id` bypass stays RAW to match: Go assigns the flag + * value straight to `Config.ProjectId` without going through `Validate` + * (`internal/stop/stop.go:19-20`). */ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { @@ -51,11 +62,12 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - return legacyResolveLocalProjectId( + const resolved = legacyResolveLocalProjectId( process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); + return legacySanitizeProjectId(resolved); }, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 3f56eead39..eadbd06fb2 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -223,6 +223,52 @@ describe("legacy stop integration", () => { }, ); + it.live( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => { + // Go's Config.Validate rewrites Config.ProjectId to its sanitized form once + // at config-load time (pkg/config/config.go:938-944); every later reader — + // including the Docker label `start` writes — sees that same sanitized + // string. Filtering on the raw value here would match nothing `start` + // ever labeled. + const { layer, child } = setup({ + configuredProjectId: "My App!!", + route: defaultRoute(), + }); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=My_App_", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => { + // Go assigns the --project-id flag value straight to Config.ProjectId + // without going through Validate (internal/stop/stop.go:19-20), so this + // path must NOT sanitize even though the default (config-derived) path does. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("Raw Value!!") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=Raw Value!!", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("stops every project's containers with --all without reading config.toml", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 540029737b..93ac55723b 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -32,15 +32,30 @@ function truncateText(text: string, maxLength: number) { return text.length > maxLength ? text.slice(0, maxLength) : text; } -/** Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading - * `_.-`, and cap at 40 chars. */ -function sanitizeProjectId(src: string) { +/** + * Go's `GetId` sanitisation: replace invalid runs with `_`, strip leading + * `_.-`, and cap at 40 chars. + * + * Exported because it is not only a container-*naming* concern: Go's + * `Config.Validate` (`pkg/config/config.go:938-944`) rewrites `c.ProjectId` + * to this same sanitized form **in place, once, at config-load time** (every + * `flags.LoadConfig` call ends in `Load` -> `Validate`), and every later use + * of `Config.ProjectId` — including the Docker LABEL value written by `start` + * (`internal/utils/docker.go:375`: `config.Labels[CliProjectLabel] = + * Config.ProjectId`) — reads that already-sanitized singleton. `GetId` itself + * performs no sanitisation of its own; it just reads the pre-sanitized value. + * So on the config/env-derived (non-`--project-id`) path, callers building a + * Docker label FILTER must sanitize too, or a `project_id` like `"my app"` + * filters on the raw string while `start` labeled the sanitized one and never + * matches anything (see `legacyCliProjectFilterValue`'s doc comment). + */ +export function legacySanitizeProjectId(src: string) { const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); } function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${sanitizeProjectId(projectId)}`; + return `supabase_${name}_${legacySanitizeProjectId(projectId)}`; } /** `utils.DbId` — the local Postgres container name. */ @@ -86,14 +101,21 @@ export function legacyServiceContainerIds(projectId: string): ReadonlyArray` (where one exists, + * e.g. `stop`) is Go's one exception: it assigns straight to + * `Config.ProjectId` without going through `Validate` + * (`apps/cli-go/internal/stop/stop.go:19-20`), so that path must stay raw/ + * unsanitized to match. There is also no injection risk either way: this + * value is always passed as a single argv element to a spawned process + * (never through a shell), so a malformed value can only make Docker's own + * filter parsing reject it or match nothing — it cannot break out into + * another command. */ export function legacyCliProjectFilterValue(projectId: string): string { if (projectId.length === 0) return LEGACY_CLI_PROJECT_LABEL; diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index ba33e89607..9c07805652 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -4,6 +4,7 @@ import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, } from "./legacy-docker-ids.ts"; @@ -65,4 +66,36 @@ describe("legacyCliProjectFilterValue", () => { it("returns label=projectId when a project id is given", () => { expect(legacyCliProjectFilterValue("my-app")).toBe(`${LEGACY_CLI_PROJECT_LABEL}=my-app`); }); + + it("must be sanitized by the caller for the label to match what start wrote", () => { + // This function is a pure pass-through by design (see its doc comment) — a + // dirty config/env-derived id must be sanitized by the caller BEFORE being + // passed here, matching Go's Config.Validate sanitizing Config.ProjectId + // once at config-load time so every reader (including the Docker label + // `start` writes) sees the same string. + const dirty = "My App!!"; + expect(legacyCliProjectFilterValue(dirty)).toBe(`${LEGACY_CLI_PROJECT_LABEL}=My App!!`); + expect(legacyCliProjectFilterValue(legacySanitizeProjectId(dirty))).toBe( + `${LEGACY_CLI_PROJECT_LABEL}=My_App_`, + ); + }); +}); + +describe("legacySanitizeProjectId", () => { + it("replaces invalid character runs with a single underscore", () => { + expect(legacySanitizeProjectId("My App!!")).toBe("My_App_"); + }); + + it("strips leading underscore/dot/dash runs", () => { + expect(legacySanitizeProjectId("...hidden-app")).toBe("hidden-app"); + }); + + it("caps the result at 40 characters", () => { + const long = "a".repeat(50); + expect(legacySanitizeProjectId(long)).toBe("a".repeat(40)); + }); + + it("leaves an already-clean id unchanged", () => { + expect(legacySanitizeProjectId("my-app_123")).toBe("my-app_123"); + }); }); From d362f4188abd5e02b83371c8fec2fc6f6e7b7be0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 12:32:41 +0100 Subject: [PATCH 04/22] fix(cli): honor image-name exclusions in status (review: PRRT_kwDOErm0O86N3Lyo) Go's status.toValues() gates each service on `--exclude` matching either the container id or the Docker image's short name (ShortContainerImageName). The TS port only checked container ids. Port the short-name extraction and check it against the same default images the embedded Dockerfile manifest already provides, since the relevant Go config fields (KongImage, Image, etc.) all carry `toml:"-"` and are never user-overridable. --- .../legacy/commands/status/SIDE_EFFECTS.md | 8 +- .../legacy/commands/status/status.values.ts | 60 ++++++++++-- .../status/status.values.unit.test.ts | 95 +++++++++++++++++++ 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 4d9579644d..4fecbad100 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -157,9 +157,11 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via - When neither `docker` nor `podman` can be spawned at all, the error message names the actual root cause (e.g. "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH") rather than a generic "failed to ..." string. -- `--exclude ` (hidden) omits a service from the value map by container id only — - Go additionally supports excluding by Docker image short-name, which has no `@supabase/config` - schema equivalent to check against, so that branch is not replicated (documented divergence). +- `--exclude ` (hidden) omits a service from the value map when `value` matches either its + container id or its default Docker image short name (Go's `ShortContainerImageName`, e.g. + `storage-api` for the storage service, `edge-runtime` for edge functions) — the default image + is read from the same embedded Dockerfile manifest Go parses, so a version bump there is picked + up automatically without needing to read the `.temp/-version` pin file. - `--ignore-health-check` (hidden) skips the db container health assertion entirely and always exits `0`, matching Go's early-return in `Run()`. - Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 6b6fa3d5b9..4b8c894fdc 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -1,5 +1,6 @@ import type { ProjectConfig } from "@supabase/config"; +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; import { legacyResolveLocalConfigValues, @@ -174,6 +175,33 @@ export function legacyStatusContainerIds(projectId: string): LegacyStatusContain }; } +/** + * Port of Go's `utils.ShortContainerImageName` (`internal/utils/misc.go:33-39,75`): + * extracts the repo name between the (first) `/` and the (last) `:`, falling back to + * the full string when the image ref doesn't match (no slash, or no tag). + */ +export function legacyShortContainerImageName(imageName: string): string { + const match = /\/(.*):/.exec(imageName); + return match?.[1] ?? imageName; +} + +// Default image short names Go's `--exclude` also matches against +// (`internal/status/status.go:55-61`), one per gated service. Sourced from the same +// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// there is picked up automatically. Pinned-version substitution +// (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the +// first `:`, which `legacyShortContainerImageName` discards — so these are invariant to +// version pinning and no `.temp/-version` file needs to be read here. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( + dockerfileServiceImage("edgeruntime"), +); + export interface LegacyStatusValuesResult { readonly values: Record; readonly names: LegacyStatusOutputNames; @@ -182,8 +210,11 @@ export interface LegacyStatusValuesResult { /** * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). - * `excluded` matches by container id only — Go's `ShortContainerImageName` branch - * has no schema equivalent to check against (decision #3 in the port plan). + * `excluded` matches each gated service by its container id (`legacyStatusContainerIds`) + * OR its default Docker image short name (`shortContainerImageName` above) — the 6 + * relevant Go config fields (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, + * `Inbucket.Image`, `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're + * never user-overridable and the default image is always the one to check. */ export function legacyStatusValues( config: ProjectConfig, @@ -196,13 +227,24 @@ export function legacyStatusValues( const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); - const kongEnabled = config.api.enabled && !isExcluded(containerIds.kong); - const postgrestEnabled = kongEnabled && !isExcluded(containerIds.rest); - const studioEnabled = config.studio.enabled && !isExcluded(containerIds.studio); - const authEnabled = config.auth.enabled && !isExcluded(containerIds.auth); - const inbucketEnabled = config.local_smtp.enabled && !isExcluded(containerIds.inbucket); - const storageEnabled = config.storage.enabled && !isExcluded(containerIds.storage); - const functionsEnabled = config.edge_runtime.enabled && !isExcluded(containerIds.edgeRuntime); + const kongEnabled = + config.api.enabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const postgrestEnabled = + kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + const studioEnabled = + config.studio.enabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + const authEnabled = + config.auth.enabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + const inbucketEnabled = + config.local_smtp.enabled && + !isExcluded(containerIds.inbucket) && + !isExcluded(MAILPIT_IMAGE_NAME); + const storageEnabled = + config.storage.enabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + const functionsEnabled = + config.edge_runtime.enabled && + !isExcluded(containerIds.edgeRuntime) && + !isExcluded(EDGE_RUNTIME_IMAGE_NAME); // Go always sets db.url unconditionally, before any gating (status.go:52). const values: Record = { diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index 152197e119..bee78682a1 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { + legacyShortContainerImageName, legacyStatusContainerIds, legacyStatusValues, type LegacyStatusContainerIds, @@ -72,6 +73,17 @@ describe("legacyStatusValues", () => { expect(values.API_URL).toBeUndefined(); }); + it("omits API_URL when the kong image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["kong"], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeUndefined(); + }); + it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { const config = baseConfig({ api: { enabled: false } }); const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); @@ -103,6 +115,19 @@ describe("legacyStatusValues", () => { expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); }); + + it("omits REST/GraphQL when the postgrest image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["postgrest"], + NO_OVERRIDES, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); }); describe("functions gating", () => { @@ -139,6 +164,19 @@ describe("legacyStatusValues", () => { ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); + + it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { + // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's + // build alias (`edgeruntime`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["edge-runtime"], + NO_OVERRIDES, + ); + expect(values.FUNCTIONS_URL).toBeUndefined(); + }); }); describe("studio / mcp gating", () => { @@ -170,6 +208,17 @@ describe("legacyStatusValues", () => { expect(values.STUDIO_URL).toBeUndefined(); }); + it("omits STUDIO_URL when the studio image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["studio"], + NO_OVERRIDES, + ); + expect(values.STUDIO_URL).toBeUndefined(); + }); + it("includes MCP_URL only when both kong and studio are enabled", () => { const { values } = legacyStatusValues( baseConfig(), @@ -230,6 +279,17 @@ describe("legacyStatusValues", () => { ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); + + it("omits all 5 auth fields when the gotrue image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["gotrue"], + NO_OVERRIDES, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); }); describe("inbucket/mailpit gating", () => { @@ -262,6 +322,17 @@ describe("legacyStatusValues", () => { ); expect(values.MAILPIT_URL).toBeUndefined(); }); + + it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["mailpit"], + NO_OVERRIDES, + ); + expect(values.MAILPIT_URL).toBeUndefined(); + }); }); describe("storage / s3 gating", () => { @@ -296,6 +367,19 @@ describe("legacyStatusValues", () => { expect(values.STORAGE_S3_URL).toBeUndefined(); }); + it("omits storage S3 fields when the storage-api image short name is excluded", () => { + // The image repo name (`supabase/storage-api`) differs from the Dockerfile's + // build alias (`storage`) — the short name Go matches against is the former. + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + ["storage-api"], + NO_OVERRIDES, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + }); + it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); @@ -372,6 +456,17 @@ describe("legacyStatusValues", () => { }); }); +describe("legacyShortContainerImageName", () => { + it("extracts the repo name between the first slash and the last colon", () => { + expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); + expect(legacyShortContainerImageName("library/kong:2.8.1")).toBe("kong"); + }); + + it("falls back to the full string when there is no slash/tag to extract", () => { + expect(legacyShortContainerImageName("kong")).toBe("kong"); + }); +}); + describe("legacyStatusContainerIds", () => { it("derives every named field from legacyServiceContainerIds's fixed array order", () => { const ids = legacyStatusContainerIds("demo"); From 4cfa1ecd93fdca1c79ac393ee2d1c198230e306a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:38 +0100 Subject: [PATCH 05/22] fix(cli): treat empty --project-id as unset in stop (review: PRRT_kwDOErm0O86N4wLX) Go's stop.Run checks len(projectId) > 0 (internal/stop/stop.go:18), not just whether --project-id was set, so an explicit but empty value falls through to config.toml resolution. The TS port only checked Option.isSome, so `--project-id ""` resolved to the bare all-projects label filter instead. --- .../src/legacy/commands/stop/stop.handler.ts | 9 ++++++++- .../commands/stop/stop.integration.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index e6f5dcebfc..0d2ef56339 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -46,11 +46,18 @@ import { * explicit `--project-id` bypass stays RAW to match: Go assigns the flag * value straight to `Config.ProjectId` without going through `Validate` * (`internal/stop/stop.go:19-20`). + * + * Go's check is `len(projectId) > 0` (`internal/stop/stop.go:18`), not merely + * "was the flag set" — an explicit but empty `--project-id ""` falls through + * to the config.toml branch exactly like an absent flag, so that's mirrored + * here with a non-empty check rather than `Option.isSome` alone. */ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProjectIdFilter")( function* (flags: LegacyStopFlags, cliConfig: LegacyCliConfig["Service"]) { if (flags.all) return ""; - if (Option.isSome(flags.projectId)) return flags.projectId.value; + if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { + return flags.projectId.value; + } // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index eadbd06fb2..edbd1031d2 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -326,6 +326,25 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("falls back to config.toml when --project-id is an empty string", () => { + // Go's check is `len(projectId) > 0` (internal/stop/stop.go:18), not just + // "was --project-id set" — an empty value must fall through to config.toml + // exactly like an absent flag, not resolve to the bare/all-projects filter. + const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); + return Effect.gen(function* () { + yield* legacyStop(flags({ projectId: Option.some("") })); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { From db0ea333ee335836db800dc33908937c7afbdc08 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:44 +0100 Subject: [PATCH 06/22] fix(cli): CSV-split status StringSlice flags (review: PRRT_kwDOErm0O86N4wLa, PRRT_kwDOErm0O86N4wLu) Go registers both --override-name and --exclude as pflag StringSliceVar (cmd/status.go:36-37), which CSV-splits each occurrence and accumulates across repeats. The TS flags only handled repetition, so a single comma-separated value like `--exclude kong,auth` produced one malformed entry instead of two. Reuses the shared legacyParseStringSliceFlag already applied to sso/postgres-config for the same StringSlice parity gap. --- .../legacy/commands/status/status.command.ts | 39 +++++++--- .../status/status.command.unit.test.ts | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/legacy/commands/status/status.command.unit.test.ts diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts index d08c723235..12be889ebb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -5,24 +5,43 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { LEGACY_RESOURCE_OUTPUT_FORMATS } from "../../shared/legacy-go-output-flag.ts"; +import { legacyParseStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import { legacyStatus } from "./status.handler.ts"; -const config = { - overrideName: Flag.string("override-name").pipe( - Flag.atLeast(0), - Flag.withDescription("Override specific variable names."), - Flag.withDefault([] as ReadonlyArray), - ), - exclude: Flag.string("exclude").pipe( +/** + * Go registers both `--override-name` and `--exclude` as pflag `StringSliceVar` + * (`cmd/status.go:36-37`), which CSV-splits each occurrence and accumulates + * across repeats — `--override-name a=1,b=2` is two overrides, not one. Effect's + * `Flag.atLeast(0)` only handles repetition, so every occurrence needs the same + * `legacyParseStringSliceFlag` normalization already used for `sso`/`postgres-config`. + */ +function csvStringSliceFlag(name: string) { + return Flag.string(name).pipe( Flag.atLeast(0), - Flag.withDescription("Names of containers to omit from output."), + Flag.mapTryCatch( + (rawValues) => legacyParseStringSliceFlag(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.withDefault([] as ReadonlyArray), - Flag.withHidden, - ), + ); +} + +export const legacyStatusOverrideNameFlag = csvStringSliceFlag("override-name").pipe( + Flag.withDescription("Override specific variable names."), +); + +export const legacyStatusExcludeFlag = csvStringSliceFlag("exclude").pipe( + Flag.withDescription("Names of containers to omit from output."), + Flag.withHidden, +); + +const config = { + overrideName: legacyStatusOverrideNameFlag, + exclude: legacyStatusExcludeFlag, ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe( Flag.withDescription("Ignore unhealthy services and exit 0"), Flag.withHidden, diff --git a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts new file mode 100644 index 0000000000..257f805382 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts @@ -0,0 +1,75 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { legacyStatusExcludeFlag, legacyStatusOverrideNameFlag } from "./status.command.ts"; + +describe("legacy status --override-name flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple overrides", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); + }); + + test("accumulates repeated occurrences, each CSV-split", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ + flags: { "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, overrideName] = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(overrideName).toEqual([]); + }); + + test("rejects malformed CSV (unterminated quote)", async () => { + const exit = await Effect.runPromise( + legacyStatusOverrideNameFlag + .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)) + .pipe(Effect.exit), + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("legacy status --exclude flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple exclusions", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual(["kong", "auth"]); + }); + + test("defaults to an empty array when unset", async () => { + const [, exclude] = await Effect.runPromise( + legacyStatusExcludeFlag + .parse({ flags: {}, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(exclude).toEqual([]); + }); +}); From 003c83fc7568135352e6c4cc19b2046de86a302f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 13:25:49 +0100 Subject: [PATCH 07/22] fix(cli): reject short jwt secrets in status (review: PRRT_kwDOErm0O86N4wL1) Go's Config.Validate fails config-load when auth.jwt_secret is set but shorter than 16 characters (pkg/config/apikeys.go:45-47), before any command can render output. The TS resolver accepted any non-empty value and signed ANON_KEY/SERVICE_ROLE_KEY with it, letting `status -o env/json` succeed and print keys for a config the Go CLI and local stack both reject. --- .../legacy/commands/status/SIDE_EFFECTS.md | 5 +++- .../legacy/commands/status/status.errors.ts | 11 +++++++++ .../legacy/commands/status/status.handler.ts | 15 ++++++++++-- .../status/status.integration.test.ts | 19 +++++++++++++++ .../shared/legacy-local-config-values.ts | 24 ++++++++++++++++++- .../legacy-local-config-values.unit.test.ts | 15 +++++++++++- 6 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 4fecbad100..7a9164c7ac 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -43,6 +43,7 @@ resolved from local `config.toml` and the local Docker daemon. | `1` | the db container inspect call failed (including "not found") — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | +| `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | ## Telemetry Events Fired @@ -167,7 +168,9 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via - Default `auth.anon_key`/`auth.service_role_key`/`auth.jwt_secret` values are generated via a Go-byte-exact HS256 signer (`legacy-go-jwt.ts`), not `@supabase/stack`'s `generateJwt` — the latter uses a different issuer, expiry, and claim order that would not match what Go prints - for local dev keys. + for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command + (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load + time before any command can render output. - `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced identically in `legacy-local-config-values.ts`. diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts index 786e7fa8ff..33da3e92f6 100644 --- a/apps/cli/src/legacy/commands/status/status.errors.ts +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -33,3 +33,14 @@ export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusD export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ readonly message: string; }> {} + +/** + * `config.toml` resolved to a value `Config.Validate` would reject before status + * ever renders — e.g. an `auth.jwt_secret` shorter than 16 characters + * (`pkg/config/apikeys.go:45-47`). + */ +export class LegacyStatusInvalidConfigError extends Data.TaggedError( + "LegacyStatusInvalidConfigError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 21a4026add..4db441befc 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -31,6 +31,7 @@ import { LegacyStatusDbInspectError, LegacyStatusDbNotReadyError, LegacyStatusDbNotRunningError, + LegacyStatusInvalidConfigError, LegacyStatusListError, LegacyStatusOverrideParseError, } from "./status.errors.ts"; @@ -182,8 +183,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `names` is intentionally unused here: the pretty-mode branch below // recomputes with an empty override map (matching Go), and every other - // branch only needs `values`. - const { values } = legacyStatusValues(config, containerIds, hostname, excluded, overrides); + // branch only needs `values`. `legacyStatusValues` can throw + // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) — Go's + // `Config.Validate` rejects that at config-load time, before this command + // would ever render anything, so it's surfaced here as a hard failure + // rather than silently signing with the too-short secret. + const { values } = yield* Effect.try({ + try: () => legacyStatusValues(config, containerIds, hostname, excluded, overrides), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over // --output-format; -o pretty/unset falls through to text/json/stream-json. diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 28fa703817..098cf66b0c 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -272,6 +272,25 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { + // Go's Config.Validate rejects this at config-load time + // (pkg/config/apikeys.go:45-47), before any command can render output. + const { layer, child } = setup({ + configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStatus(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusInvalidConfigError"); + expect(JSON.stringify(exit.cause)).toContain( + "Invalid config for auth.jwt_secret. Must be at least 16 characters", + ); + } + expect(child.spawned.some((s) => s.args[0] === "ps")).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 2c6b4fac51..f730369d7f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -63,9 +63,30 @@ function apiUrlWithPath(apiExternalUrl: string, path: string): string { return `${apiExternalUrl}${path}`; } +/** + * Thrown by {@link legacyResolveLocalConfigValues} when `auth.jwt_secret` is + * configured but too short to sign with, mirroring Go's `Config.Validate` + * (`pkg/config/apikeys.go:45-47`) — that check runs at config-load time, before + * any command renders output, so no local dev stack can even start with a + * short secret. + */ +export class LegacyInvalidJwtSecretError extends Error { + constructor() { + super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); + this.name = "LegacyInvalidJwtSecretError"; + } +} + +/** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ +const MIN_JWT_SECRET_LENGTH = 16; + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { - return configured !== undefined && configured.length > 0 ? configured : defaultJwtSecret; + if (configured === undefined || configured.length === 0) return defaultJwtSecret; + if (configured.length < MIN_JWT_SECRET_LENGTH) { + throw new LegacyInvalidJwtSecretError(); + } + return configured; } function resolveOpaqueKey(configured: string | undefined, fallback: string): string { @@ -82,6 +103,7 @@ function resolveSignedKey( : legacyGenerateGoJwt(jwtSecret, role); } +/** @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. */ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 0abfb35a57..c23eca5d8b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -2,7 +2,10 @@ import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; +import { + LegacyInvalidJwtSecretError, + legacyResolveLocalConfigValues, +} from "./legacy-local-config-values.ts"; const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); @@ -98,6 +101,16 @@ describe("legacyResolveLocalConfigValues", () => { expect(values.anonKey).not.toBe(""); }); + it("rejects a configured jwt_secret shorter than 16 characters", () => { + // Go's Config.Validate fails this at config-load time, before any command + // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown + // error here rather than silently signing with the too-short secret. + const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1")).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + it("hardcodes the Go-parity local S3 credentials", () => { const config = baseConfig(); const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); From d74f043d0e87b878f55e6e96f1e41f46fb695fdc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:00:34 +0100 Subject: [PATCH 08/22] fix(cli): honor SUPABASE_AUTH_* env overrides in status keys (review: PRRT_kwDOErm0O86N4wLx) Go's config loader binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() (pkg/config/config.go:529-535), so SUPABASE_AUTH_JWT_SECRET/PUBLISHABLE_KEY/ SECRET_KEY/ANON_KEY/SERVICE_ROLE_KEY override the corresponding config.toml value at higher precedence. legacyResolveLocalConfigValues only read the decoded config object, so a local stack started with those env overrides had `status` print keys that didn't match the running Auth service. Scoped to exactly the 5 auth fields this module reads, not a general @supabase/config port of Viper's AutomaticEnv. --- .../legacy/commands/status/SIDE_EFFECTS.md | 38 +++- .../shared/legacy-local-config-values.ts | 110 ++++++++++- .../legacy-local-config-values.unit.test.ts | 180 ++++++++++++++++-- 3 files changed, 298 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 7a9164c7ac..53b04e9144 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ------------------------------------------------------ | ------ | -------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | ## Files Written @@ -23,11 +24,20 @@ resolved from local `config.toml` and the local Docker daemon. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | -| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | -| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| Variable | Purpose | Required? | +| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id | no (falls back to config.toml `project_id` → workdir basename) | +| `SUPABASE_WORKDIR` | overrides the resolved project workdir | no (falls back to `--workdir` → walk-up search for `config.toml` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs | no (falls back to `DOCKER_HOST`'s tcp host → `127.0.0.1`) | +| `SUPABASE_AUTH_JWT_SECRET` | overrides `auth.jwt_secret` | no | +| `SUPABASE_AUTH_PUBLISHABLE_KEY` | overrides `auth.publishable_key` | no | +| `SUPABASE_AUTH_SECRET_KEY` | overrides `auth.secret_key` | no | +| `SUPABASE_AUTH_ANON_KEY` | overrides `auth.anon_key` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | overrides `auth.service_role_key` | no | + +The `SUPABASE_AUTH_*` vars mirror Go's Viper `AutomaticEnv` (`SetEnvPrefix("SUPABASE")` + +`.`→`_` key replacer, `pkg/config/config.go:529-535`) and take precedence over the corresponding +`config.toml` value, matching Viper's real precedence order. `docker` (or `podman` as a fallback) must be on `PATH`. @@ -44,6 +54,7 @@ resolved from local `config.toml` and the local Docker daemon. | `1` | the db container is present but not in the `running` state — health assertion, skipped by `--ignore-health-check` above | | `1` | the db container is running but its Docker health check isn't `healthy` — health assertion, skipped by `--ignore-health-check` above | | `1` | `auth.jwt_secret` is configured but shorter than 16 characters (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `auth.signing_keys_path` is configured but the file is missing/malformed, or its first key's algorithm is not `RS256`/`ES256` | ## Telemetry Events Fired @@ -171,6 +182,17 @@ Additive — no Go CLI equivalent. Emits the same resolved value map via for local dev keys. A configured `auth.jwt_secret` shorter than 16 characters fails the command (`LegacyStatusInvalidConfigError`), matching Go's `Config.Validate` rejecting it at config-load time before any command can render output. +- When `auth.signing_keys_path` is set and resolves to a non-empty JWK array, `anon_key`/ + `service_role_key` are instead signed asymmetrically (RS256/ES256) with the file's first key, + matching Go's `generateJWT` (`pkg/config/apikeys.go:76-113`) — a relative path resolves against + `/supabase`. This path is skipped entirely when `auth.anon_key`/`auth.service_role_key` + are explicitly configured. A missing/malformed file, or a first key with an algorithm other than + `RS256`/`ES256`, fails the command (`LegacyStatusInvalidConfigError`). +- `SUPABASE_AUTH_JWT_SECRET`/`SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY`/ + `SUPABASE_AUTH_ANON_KEY`/`SUPABASE_AUTH_SERVICE_ROLE_KEY` override the corresponding + `config.toml` value at higher precedence, matching Go's Viper `AutomaticEnv` — an empty env var + is treated as unset. This is scoped to exactly the 5 auth fields `status` reads; it is not a + general `@supabase/config` port of Viper's `AutomaticEnv` (which applies to every config field). - `db.password` and the `storage.s3_credentials` triple have no `@supabase/config` schema field; Go hardcodes both (`"postgres"` and the S3 access key/secret/region seen above), reproduced identically in `legacy-local-config-values.ts`. diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index f730369d7f..328755acea 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,8 +1,16 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + import type { ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { Schema } from "effect"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; -import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; /** * Go-parity derived local-dev config values, ported from `utils.Config`'s @@ -80,6 +88,20 @@ export class LegacyInvalidJwtSecretError extends Error { /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ const MIN_JWT_SECRET_LENGTH = 16; +/** + * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), + * so any config field can be overridden by a `SUPABASE_` env var — + * this resolves it for exactly the 5 auth fields this module reads, at the + * same higher-than-config.toml precedence Viper gives env vars. An empty env + * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never + * enabled in `config.go`). + */ +function envOverride(name: string, configured: string | undefined): string | undefined { + const value = process.env[name]; + return value !== undefined && value.length > 0 ? value : configured; +} + /** Go's `(a *auth) generateAPIKeys` (`pkg/config/apikeys.go:43-73`). */ function resolveJwtSecret(configured: string | undefined): string { if (configured === undefined || configured.length === 0) return defaultJwtSecret; @@ -96,20 +118,74 @@ function resolveOpaqueKey(configured: string | undefined, fallback: string): str function resolveSignedKey( configured: string | undefined, jwtSecret: string, + signingKey: LegacyJwk | undefined, role: "anon" | "service_role", ): string { - return configured !== undefined && configured.length > 0 - ? configured + if (configured !== undefined && configured.length > 0) return configured; + return signingKey !== undefined + ? legacyGenerateAsymmetricGoJwt(signingKey, role) : legacyGenerateGoJwt(jwtSecret, role); } -/** @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. */ +/** Matches Go's `JWK` struct fields (`pkg/config/auth.go:88-108`) — see `LegacyJwk`. */ +const LegacyJwkSchema = Schema.Struct({ + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + alg: Schema.optionalKey(Schema.String), + n: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)); + +/** + * Go's `Config.Validate` (`pkg/config/config.go:877-878,1059-1062`): a relative + * `signing_keys_path` resolves against `/supabase`, then the file is + * read and JSON-decoded into `[]JWK`. Only the first key is ever used + * ({@link resolveSignedKey}), matching `generateJWT`'s `a.SigningKeys[0]`. + * + * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity + * resolvers in `legacy/` use for file reads) so this function — and its large + * existing test surface — can stay a plain synchronous resolver; this is an + * optional, rarely-configured field, not worth threading Effect dependencies + * through `legacyStatusValues`/`status.handler.ts` for. + */ +function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { + const absolutePath = isAbsolute(signingKeysPath) + ? signingKeysPath + : join(workdir, "supabase", signingKeysPath); + const contents = readFileSync(absolutePath, "utf8"); + const jwks = decodeLegacyJwks(JSON.parse(contents)); + return jwks[0]; +} + +/** + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key uses an unsupported algorithm — see {@link legacyGenerateAsymmetricGoJwt}. + */ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, + workdir: string, ): LegacyLocalConfigValues { const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); - const jwtSecret = resolveJwtSecret(config.auth.jwt_secret); + const jwtSecret = resolveJwtSecret( + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), + ); + const signingKeysPath = config.auth.signing_keys_path; + const signingKey = + signingKeysPath !== undefined && signingKeysPath.length > 0 + ? loadFirstSigningKey(workdir, signingKeysPath) + : undefined; return { apiUrl: apiExternalUrl, @@ -120,11 +196,27 @@ export function legacyResolveLocalConfigValues( studioUrl: `http://${hostname}:${config.studio.port}`, mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, - publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), - secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), + publishableKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key), + defaultPublishableKey, + ), + secretKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key), + defaultSecretKey, + ), jwtSecret, - anonKey: resolveSignedKey(config.auth.anon_key, jwtSecret, "anon"), - serviceRoleKey: resolveSignedKey(config.auth.service_role_key, jwtSecret, "service_role"), + anonKey: resolveSignedKey( + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key), + jwtSecret, + signingKey, + "anon", + ), + serviceRoleKey: resolveSignedKey( + envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key), + jwtSecret, + signingKey, + "service_role", + ), storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index c23eca5d8b..972e9bd595 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1,22 +1,36 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { importJWK, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyInvalidJwtSecretError, legacyResolveLocalConfigValues, } from "./legacy-local-config-values.ts"; const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-local-config-values-test"; function baseConfig(overrides: Record = {}): ProjectConfig { return decodeConfig({ project_id: "test", ...overrides }); } +/** RSA JWK matching Go's `JWK` struct field names (kty/n/e/d/p/q/dp/dq/qi). */ +function generateRsaJwk(): Record { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; +} + describe("legacyResolveLocalConfigValues", () => { it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("http://127.0.0.1:54321"); expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); @@ -30,32 +44,32 @@ describe("legacyResolveLocalConfigValues", () => { it("uses https and the configured port when api.tls.enabled", () => { const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); it("uses api.external_url verbatim when configured", () => { const config = baseConfig({ api: { external_url: "https://example.test" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("https://example.test"); expect(values.restUrl).toBe("https://example.test/rest/v1"); }); it("brackets an IPv6 hostname when building host:port", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "::1"); + const values = legacyResolveLocalConfigValues(config, "::1", WORKDIR); expect(values.apiUrl).toBe("http://[::1]:54321"); }); it("builds the db URL with the hardcoded postgres password", () => { const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); it("falls back to the default JWT secret and opaque keys when unset", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); @@ -65,14 +79,14 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.publishableKey).toBe("sb_publishable_custom"); expect(values.secretKey).toBe("sb_secret_custom"); }); it("signs the default anon/service_role JWTs from the resolved secret", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we // only assert the resolver wires the default secret through to both roles. const [, anonPayload] = values.anonKey.split("."); @@ -89,14 +103,14 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.anonKey).toBe("configured-anon"); expect(values.serviceRoleKey).toBe("configured-service-role"); }); it("signs anon/service_role JWTs from a configured jwt_secret", () => { const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.jwtSecret).toBe("a".repeat(32)); expect(values.anonKey).not.toBe(""); }); @@ -106,18 +120,158 @@ describe("legacyResolveLocalConfigValues", () => { // can render output (pkg/config/apikeys.go:45-47) — reproduced as a thrown // error here rather than silently signing with the too-short secret. const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1")).toThrow( + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( LegacyInvalidJwtSecretError, ); }); it("hardcodes the Go-parity local S3 credentials", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1"); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); expect(values.storageS3SecretAccessKey).toBe( "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", ); expect(values.storageS3Region).toBe("local"); }); + + describe("SUPABASE_AUTH_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. + const ENV_KEYS = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides jwt_secret even when config.toml sets one", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("b".repeat(32)); + }); + + it("overrides publishable_key/secret_key", () => { + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; + process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; + const config = baseConfig({ + auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("env-publishable"); + expect(values.secretKey).toBe("env-secret"); + }); + + it("overrides anon_key/service_role_key", () => { + process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; + process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; + const config = baseConfig({ + auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("env-anon"); + expect(values.serviceRoleKey).toBe("env-service-role"); + }); + + it("treats an empty env var as unset, matching Viper's default", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + }); + + it("still applies the short-secret validation to an env-provided jwt_secret", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + }); + + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + + function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); + } + + it("signs anon/service_role with the first RS256 key in the file", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + + const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }); + + it("resolves a relative signing_keys_path against /supabase", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("still prefers an explicit anon_key/service_role_key over signing keys", () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }); + + it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { + writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); + + it("throws when the signing keys file does not exist", () => { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow(); + }); + + it("throws when the first key uses an unsupported algorithm", () => { + writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "unsupported algorithm: RS512", + ); + }); + }); }); From 68410196ba78f20d2a682c6b13211f9e63b6db16 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:00:46 +0100 Subject: [PATCH 09/22] fix(cli): sign status keys with asymmetric signing keys when configured (review: PRRT_kwDOErm0O86N4wLk) Go's generateJWT signs anon/service_role with the first key in auth.signing_keys_path (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK array (pkg/config/apikeys.go:76-113). The TS resolver only ever HMAC-signed with jwt_secret. Ports GenerateAsymmetricJWT via legacyGenerateAsymmetricGoJwt (RFC 7517 JWK -> Node crypto private key, ieee-p1363 signature encoding for ES256's raw r||s format) and wires it into legacyResolveLocalConfigValues, which now needs workdir to resolve a relative signing_keys_path against /supabase. --- .../legacy/commands/status/status.handler.ts | 21 ++- .../legacy/commands/status/status.values.ts | 3 +- .../status/status.values.unit.test.ts | 177 ++++++++++++++++-- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 83 +++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 79 +++++++- 5 files changed, 336 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 4db441befc..f8666006f9 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -184,12 +184,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `names` is intentionally unused here: the pretty-mode branch below // recomputes with an empty override map (matching Go), and every other // branch only needs `values`. `legacyStatusValues` can throw - // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) — Go's - // `Config.Validate` rejects that at config-load time, before this command - // would ever render anything, so it's surfaced here as a hard failure - // rather than silently signing with the too-short secret. + // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) or a + // signing-keys-file read/parse error — Go's `Config.Validate` rejects both + // at config-load time, before this command would ever render anything, so + // they're surfaced here as a hard failure rather than silently falling + // back to a default/HMAC-signed key. const { values } = yield* Effect.try({ - try: () => legacyStatusValues(config, containerIds, hostname, excluded, overrides), + try: () => + legacyStatusValues(config, containerIds, hostname, excluded, overrides, cliConfig.workdir), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), @@ -234,7 +236,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. // Recompute with an empty override map so the rendered table matches Go // exactly instead of leaking `--override-name` into pretty-mode output. - const pretty = legacyStatusValues(config, containerIds, hostname, excluded, new Map()); + const pretty = legacyStatusValues( + config, + containerIds, + hostname, + excluded, + new Map(), + cliConfig.workdir, + ); yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 4b8c894fdc..0b6e301c58 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -222,8 +222,9 @@ export function legacyStatusValues( hostname: string, excluded: ReadonlyArray, overrides: ReadonlyMap, + workdir: string, ): LegacyStatusValuesResult { - const local = legacyResolveLocalConfigValues(config, hostname); + const local = legacyResolveLocalConfigValues(config, hostname, workdir); const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); diff --git a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts index bee78682a1..dd277f56f3 100644 --- a/apps/cli/src/legacy/commands/status/status.values.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -28,6 +28,7 @@ const CONTAINER_IDS: LegacyStatusContainerIds = { const HOSTNAME = "127.0.0.1"; const NONE: ReadonlyArray = []; const NO_OVERRIDES = new Map(); +const WORKDIR = "/tmp/status-values-test"; describe("legacyStatusValues", () => { it("emits DB_URL unconditionally, even when every other service is disabled/excluded", () => { @@ -39,7 +40,14 @@ describe("legacyStatusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); }); @@ -52,13 +60,21 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); }); it("omits API_URL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.API_URL).toBeUndefined(); }); @@ -69,6 +85,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.kong], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeUndefined(); }); @@ -80,13 +97,21 @@ describe("legacyStatusValues", () => { HOSTNAME, ["kong"], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeUndefined(); }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); @@ -98,6 +123,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.rest], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -111,6 +137,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); @@ -123,6 +150,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["postgrest"], NO_OVERRIDES, + WORKDIR, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -138,19 +166,34 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.FUNCTIONS_URL).toBeDefined(); }); it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -161,6 +204,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.edgeRuntime], NO_OVERRIDES, + WORKDIR, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -174,6 +218,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["edge-runtime"], NO_OVERRIDES, + WORKDIR, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -187,13 +232,21 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.STUDIO_URL).toBeDefined(); }); it("omits STUDIO_URL when studio.enabled is false", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -204,6 +257,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.studio], NO_OVERRIDES, + WORKDIR, ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -215,6 +269,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["studio"], NO_OVERRIDES, + WORKDIR, ); expect(values.STUDIO_URL).toBeUndefined(); }); @@ -226,19 +281,34 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.MCP_URL).toBeDefined(); }); it("omits MCP_URL when kong is disabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MCP_URL).toBeUndefined(); }); it("omits MCP_URL when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MCP_URL).toBeUndefined(); }); }); @@ -251,6 +321,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.SECRET_KEY).toBeDefined(); @@ -261,7 +332,14 @@ describe("legacyStatusValues", () => { it("omits all 5 auth fields when auth.enabled is false", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); expect(values.SECRET_KEY).toBeUndefined(); expect(values.JWT_SECRET).toBeUndefined(); @@ -276,6 +354,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.auth], NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -287,6 +366,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["gotrue"], NO_OVERRIDES, + WORKDIR, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -300,6 +380,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); @@ -307,7 +388,14 @@ describe("legacyStatusValues", () => { it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.MAILPIT_URL).toBeUndefined(); expect(values.INBUCKET_URL).toBeUndefined(); }); @@ -319,6 +407,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.inbucket], NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -330,6 +419,7 @@ describe("legacyStatusValues", () => { HOSTNAME, ["mailpit"], NO_OVERRIDES, + WORKDIR, ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -343,6 +433,7 @@ describe("legacyStatusValues", () => { HOSTNAME, NONE, NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeDefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); @@ -352,7 +443,14 @@ describe("legacyStatusValues", () => { it("omits storage S3 fields when storage.enabled is false", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -363,6 +461,7 @@ describe("legacyStatusValues", () => { HOSTNAME, [CONTAINER_IDS.storage], NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -376,13 +475,21 @@ describe("legacyStatusValues", () => { HOSTNAME, ["storage-api"], NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = legacyStatusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); }); @@ -391,7 +498,14 @@ describe("legacyStatusValues", () => { describe("--override-name remapping", () => { it("remaps a field's output KEY while leaving the value unchanged", () => { const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.API_URL).toBeUndefined(); expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); }); @@ -401,7 +515,14 @@ describe("legacyStatusValues", () => { ["api.url", "CUSTOM_API_URL"], ["db.url", "CUSTOM_DB_URL"], ]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_API_URL).toBeDefined(); expect(values.CUSTOM_DB_URL).toBeDefined(); expect(values.API_URL).toBeUndefined(); @@ -410,7 +531,14 @@ describe("legacyStatusValues", () => { it("leaves unrelated fields at their default name when only one is overridden", () => { const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.REST_URL).toBeDefined(); }); @@ -420,7 +548,14 @@ describe("legacyStatusValues", () => { ["auth.anon_key", "CUSTOM_ANON_KEY"], ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], ]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_JWT_SECRET).toBeDefined(); expect(values.CUSTOM_ANON_KEY).toBeDefined(); expect(values.CUSTOM_SERVICE_ROLE_KEY).toBeDefined(); @@ -431,7 +566,14 @@ describe("legacyStatusValues", () => { it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = legacyStatusValues(baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides); + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + overrides, + WORKDIR, + ); expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBeUndefined(); @@ -449,6 +591,7 @@ describe("legacyStatusValues", () => { HOSTNAME, excluded, NO_OVERRIDES, + WORKDIR, ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.STUDIO_URL).toBeUndefined(); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 7c6c75d178..b07701c2bd 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -1,8 +1,35 @@ -import { createHmac } from "node:crypto"; +import { createHmac, createPrivateKey, createSign } from "node:crypto"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, + * `toml`/`json` tags `kty`, `kid`, `alg`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`) — field names match exactly, so a signing-keys file can + * be parsed straight into this shape. A superset of Node's own + * `crypto.webcrypto.JsonWebKey` (which omits `kid`), so it's still assignable + * wherever that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input). + */ +export interface LegacyJwk { + readonly kty: string; + readonly kid?: string; + readonly alg?: string; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} /** * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` * keys, ported from `CustomClaims`/`generateJWT` (`apps/cli-go/pkg/config/apikeys.go:23-40,75-86`). + * {@link legacyGenerateAsymmetricGoJwt} below covers the RS256/ES256 branch of + * the same Go function, taken when `auth.signing_keys_path` is configured. * * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, @@ -36,3 +63,57 @@ export function legacyGenerateGoJwt(secret: string, role: "anon" | "service_role const signature = createHmac("sha256", secret).update(data).digest("base64url"); return `${data}.${signature}`; } + +/** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ +const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from + * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK + * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both + * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} + * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's + * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` + * explicitly before calling this function instead of falling through to + * `CustomClaims.NewToken()`'s fixed default. + * + * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` + * (RSA/EC key types) + this function's own switch on `jwk.alg`. The header key + * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically + * sorting `map[string]interface{}` keys — `kid` is only present when set on + * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * + * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA + * signature output is DER-encoded, which is not the raw (r‖s) format JWS + * requires — verified by round-tripping through `jose`'s `jwtVerify`. + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const algorithm = jwk.alg; + if (algorithm !== "RS256" && algorithm !== "ES256") { + throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); + } + const header = + jwk.kid !== undefined && jwk.kid.length > 0 + ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } + : { alg: algorithm, typ: "JWT" }; + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const payloadEncoded = base64UrlEncode( + JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), + ); + const data = `${headerEncoded}.${payloadEncoded}`; + + const privateKey = createPrivateKey({ key: jwk, format: "jwk" }); + const signature = + algorithm === "RS256" + ? createSign("RSA-SHA256").update(data).end().sign(privateKey) + : createSign("sha256") + .update(data) + .end() + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + + return `${data}.${signature.toString("base64url")}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 3c8aa39b84..c44a215d30 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -1,10 +1,32 @@ -import { createHmac } from "node:crypto"; +import { createHmac, generateKeyPairSync } from "node:crypto"; +import { importJWK, jwtVerify } from "jose"; import { describe, expect, it } from "vitest"; -import { legacyGenerateGoJwt } from "./legacy-go-jwt.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} from "./legacy-go-jwt.ts"; const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; +function generateRsaJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function generateEcJwk(kid?: string): LegacyJwk { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }); + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function publicJwkOf(jwk: LegacyJwk): LegacyJwk { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + function decodeSegment(segment: string): string { return Buffer.from(segment, "base64url").toString("utf8"); } @@ -63,3 +85,56 @@ describe("legacyGenerateGoJwt", () => { expect(a).not.toBe(b); }); }); + +describe("legacyGenerateAsymmetricGoJwt", () => { + it("signs and verifies an RS256 token from an RSA JWK", async () => { + const jwk = generateRsaJwk("rsa-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }); + + it("signs and verifies an ES256 token from an EC JWK", async () => { + const jwk = generateEcJwk("ec-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload, protectedHeader } = await jwtVerify(token, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); + expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }); + + it("omits the kid header entirely when the JWK has no kid", () => { + const jwk = generateRsaJwk(); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [header] = token.split("."); + const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); + expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); + }); + + it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { + const jwk = generateRsaJwk(); + const before = Math.floor(Date.now() / 1000); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const [, payload] = token.split("."); + const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); + const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; + expect(decoded.exp).toBeGreaterThanOrEqual(before + tenYearsSeconds); + expect(decoded.exp).toBeLessThan(before + tenYearsSeconds + 10); + }); + + it("rejects an unsupported algorithm", () => { + const jwk = { ...generateRsaJwk(), alg: "RS512" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "unsupported algorithm: RS512", + ); + }); + + it("rejects a JWK with no algorithm", () => { + const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); + }); +}); From 9a382553ff5b135af779739d4245e0f2390e7d2c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:36 +0100 Subject: [PATCH 10/22] fix(cli): validate JWK kty/curve before asymmetric signing Go's jwkToRSAPrivateKey/jwkToECDSAPrivateKey reject a JWK whose kty doesn't match its claimed alg, and an EC key whose curve isn't P-256 (pkg/config/auth.go). legacyGenerateAsymmetricGoJwt only checked alg, so an EC key forged with alg: RS256 (or a non-P-256 curve claimed as ES256) signed "successfully" and produced a spec-invalid token that silently fails verification instead of raising an error. Found independently by the security and DX reviewers in review-changes. --- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 20 +++++++++++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index b07701c2bd..6a4ddc5a47 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -78,7 +78,14 @@ const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; * `CustomClaims.NewToken()`'s fixed default. * * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` - * (RSA/EC key types) + this function's own switch on `jwk.alg`. The header key + * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` + * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires + * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / + * `jwkToECDSAPrivateKey`, which reject any other combination rather than + * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` + * do not themselves catch this: an EC key signed as RS256, or a non-P-256 + * curve signed as ES256, both "succeed" and produce a spec-invalid token that + * silently fails verification instead of raising an error). The header key * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically * sorting `map[string]interface{}` keys — `kid` is only present when set on * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. @@ -95,6 +102,17 @@ export function legacyGenerateAsymmetricGoJwt( if (algorithm !== "RS256" && algorithm !== "ES256") { throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); } + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (algorithm === "ES256") { + if (jwk.kty !== "EC") { + throw new Error(`unsupported key type: ${jwk.kty}`); + } + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + } const header = jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index c44a215d30..095ec1480f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -137,4 +137,28 @@ describe("legacyGenerateAsymmetricGoJwt", () => { "unsupported algorithm: ", ); }); + + it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { + const jwk = { ...generateEcJwk(), alg: "RS256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + }); + + it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { + const jwk = { ...generateRsaJwk(), alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + }); + + it("rejects an ES256 EC key whose curve is not P-256", () => { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); + const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + }); + + it("rejects an ES256 EC key with no curve at all", () => { + const jwk = generateEcJwk(); + const { crv: _crv, ...jwkWithoutCurve } = jwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + "unsupported curve: ", + ); + }); }); From e74201b5c88ed10c5e0ad22fe81585b13e95f4a8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:44 +0100 Subject: [PATCH 11/22] refactor(cli): resolve status state once instead of calling legacyStatusValues twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status.handler.ts called legacyStatusValues twice per invocation (real values, then pretty-mode values with an empty override map) — only the first call was wrapped in Effect.try. With signing_keys_path support this doubled file I/O + JWT signing on every text-mode run, and left the second call able to throw an uncaught exception if it ever diverged from the first. Splits legacyStatusValues into legacyResolveStatusState (the throwing half: local config resolution + gating) and legacyStatusValuesFromState (pure name remapping), so the handler resolves state once, guards it once, and reuses it for both value maps. --- .../legacy/commands/status/status.handler.ts | 38 ++++---- .../legacy/commands/status/status.values.ts | 90 ++++++++++++++++--- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index f8666006f9..6c4921d8bd 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -38,8 +38,9 @@ import { import { legacyRenderStatusPretty } from "./status.pretty.ts"; import { LEGACY_STATUS_FIELDS, + legacyResolveStatusState, legacyStatusContainerIds, - legacyStatusValues, + legacyStatusValuesFromState, } from "./status.values.ts"; /** @@ -181,22 +182,23 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // 7. --override-name KEY=VALUE parsing. const overrides = yield* parseOverrides(flags.overrideName); - // `names` is intentionally unused here: the pretty-mode branch below - // recomputes with an empty override map (matching Go), and every other - // branch only needs `values`. `legacyStatusValues` can throw - // `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`) or a - // signing-keys-file read/parse error — Go's `Config.Validate` rejects both - // at config-load time, before this command would ever render anything, so - // they're surfaced here as a hard failure rather than silently falling - // back to a default/HMAC-signed key. - const { values } = yield* Effect.try({ + // `legacyResolveStatusState` can throw `LegacyInvalidJwtSecretError` (a short + // `auth.jwt_secret`) or a signing-keys-file read/parse error — Go's + // `Config.Validate` rejects both at config-load time, before this command + // would ever render anything, so they're surfaced here as a hard failure + // rather than silently falling back to a default/HMAC-signed key. Resolved + // once and reused for both the real and pretty-mode (empty-override) value + // maps below, so a configured `signing_keys_path` is read and the anon/ + // service_role JWTs signed only once per invocation, not twice. + const state = yield* Effect.try({ try: () => - legacyStatusValues(config, containerIds, hostname, excluded, overrides, cliConfig.workdir), + legacyResolveStatusState(config, containerIds, hostname, excluded, cliConfig.workdir), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), }), }); + const { values } = legacyStatusValuesFromState(state, overrides); // 8. Output branching: Go's -o (env|json|toml|yaml) takes priority over // --output-format; -o pretty/unset falls through to text/json/stream-json. @@ -234,16 +236,10 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // `EnvSet{}` into a brand-new `CustomName{}` rather than reusing the // CLI-supplied, override-populated `names` — `--override-name` only ever // affects `printStatus`'s env/json/toml/yaml path, never the pretty table. - // Recompute with an empty override map so the rendered table matches Go - // exactly instead of leaking `--override-name` into pretty-mode output. - const pretty = legacyStatusValues( - config, - containerIds, - hostname, - excluded, - new Map(), - cliConfig.workdir, - ); + // Remap names from the already-resolved `state` (empty override map) so the + // rendered table matches Go exactly without leaking `--override-name` into + // pretty-mode output, and without a second (throwing) state resolution. + const pretty = legacyStatusValuesFromState(state, new Map()); yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index 0b6e301c58..f8165f4790 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -209,23 +209,48 @@ export interface LegacyStatusValuesResult { } /** - * Port of Go's `(*CustomName).toValues(exclude...)` (`internal/status/status.go:50-97`). - * `excluded` matches each gated service by its container id (`legacyStatusContainerIds`) - * OR its default Docker image short name (`shortContainerImageName` above) — the 6 - * relevant Go config fields (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, - * `Inbucket.Image`, `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're - * never user-overridable and the default image is always the one to check. + * Everything `toValues()` needs that does NOT depend on `--override-name` — + * i.e. every field except the output KEY remapping. Resolving this once and + * reusing it for both the env/json/toml/yaml values (real overrides) and the + * pretty-table values (Go always recomputes with an empty override map, + * `status.go:236-243`) avoids re-reading `auth.signing_keys_path` and + * re-signing the anon/service_role JWTs a second time per invocation. */ -export function legacyStatusValues( +export interface LegacyStatusState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly kongEnabled: boolean; + readonly postgrestEnabled: boolean; + readonly studioEnabled: boolean; + readonly authEnabled: boolean; + readonly inbucketEnabled: boolean; + readonly storageEnabled: boolean; + readonly functionsEnabled: boolean; +} + +/** + * Port of the non-override-dependent half of Go's `(*CustomName).toValues(exclude...)` + * (`internal/status/status.go:50-97`): resolves local config values (URLs, keys — + * can throw, see {@link legacyResolveLocalConfigValues}) and the per-service gating + * booleans. `excluded` matches each gated service by its container id + * (`legacyStatusContainerIds`) OR its default Docker image short name + * (`legacyShortContainerImageName` above) — the 6 relevant Go config fields + * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, + * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never + * user-overridable and the default image is always the one to check. + * + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key is unsupported — see {@link legacyGenerateAsymmetricGoJwt}. + */ +export function legacyResolveStatusState( config: ProjectConfig, containerIds: LegacyStatusContainerIds, hostname: string, excluded: ReadonlyArray, - overrides: ReadonlyMap, workdir: string, -): LegacyStatusValuesResult { +): LegacyStatusState { const local = legacyResolveLocalConfigValues(config, hostname, workdir); - const names = resolveOutputNames(overrides); const isExcluded = (id: string) => excluded.includes(id); const kongEnabled = @@ -247,6 +272,32 @@ export function legacyStatusValues( !isExcluded(containerIds.edgeRuntime) && !isExcluded(EDGE_RUNTIME_IMAGE_NAME); + return { + config, + local, + kongEnabled, + postgrestEnabled, + studioEnabled, + authEnabled, + inbucketEnabled, + storageEnabled, + functionsEnabled, + }; +} + +/** + * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. + * Pure and non-throwing — every failure mode of `toValues()` lives in + * {@link legacyResolveStatusState}, which runs once per `status` invocation. + */ +export function legacyStatusValuesFromState( + state: LegacyStatusState, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const { config, local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled } = state; + const names = resolveOutputNames(overrides); + // Go always sets db.url unconditionally, before any gating (status.go:52). const values: Record = { [names.dbUrl]: local.dbUrl, @@ -289,3 +340,22 @@ export function legacyStatusValues( return { values, names, local }; } + +/** + * Convenience wrapper combining {@link legacyResolveStatusState} + + * {@link legacyStatusValuesFromState} in one call — used directly by tests that + * only need a single override map. `status.handler.ts` calls the two halves + * separately so it can resolve state once and reuse it for both the real and + * pretty-mode (empty-override) value maps without recomputing `local`. + */ +export function legacyStatusValues( + config: ProjectConfig, + containerIds: LegacyStatusContainerIds, + hostname: string, + excluded: ReadonlyArray, + overrides: ReadonlyMap, + workdir: string, +): LegacyStatusValuesResult { + const state = legacyResolveStatusState(config, containerIds, hostname, excluded, workdir); + return legacyStatusValuesFromState(state, overrides); +} From 60877ba497c91cbe920ea6db494c3cea3638ccda Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 14:59:50 +0100 Subject: [PATCH 12/22] fix(cli): match Go's signing-keys error wording and add missing integration coverage Go's Config.Validate fails a bad auth.signing_keys_path with "failed to read signing keys: %w" (open failure) or "failed to decode signing keys: %w" (parse failure) (pkg/config/config.go:1059-1062). The TS port let readFileSync/JSON.parse's raw Node error text through unwrapped instead. Also adds status.integration.test.ts coverage for the SUPABASE_AUTH_* env override and asymmetric-signing-key behaviors, which previously only had unit-level coverage on the pure resolver. --- .../status/status.integration.test.ts | 42 +++++++++++++++++++ .../shared/legacy-local-config-values.ts | 25 ++++++++++- .../legacy-local-config-values.unit.test.ts | 16 ++++++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 098cf66b0c..fedccad54b 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; @@ -5,6 +6,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach } from "vitest"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -19,6 +21,10 @@ import { legacyStatus } from "./status.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); +afterEach(() => { + delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +}); + function flags(overrides: Partial = {}): LegacyStatusFlags { return { overrideName: [], @@ -291,6 +297,42 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { + // Go's Viper AutomaticEnv gives env vars higher precedence than config.toml + // (pkg/config/config.go:529-535) — a stack started with this env var set + // must report the env-derived secret, not the one in config.toml. + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"b".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + + it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { + // Go's generateJWT signs with the first key in auth.signing_keys_path + // (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK + // array (pkg/config/apikeys.go:76-113). + const { layer, out, workdir } = setup({ + goOutput: Option.some("json"), + configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', + }); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; + writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const parsed = JSON.parse(out.stdoutText) as Record; + const [headerSegment] = parsed.ANON_KEY?.split(".") ?? []; + const header = JSON.parse(Buffer.from(headerSegment ?? "", "base64url").toString()); + expect(header).toEqual({ alg: "RS256", kid: "test-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }); + it.live("reports status using schema defaults when config.toml is missing entirely", () => { // Matches Go: `flags.LoadConfig` -> `Config.Load` -> `loadFromFile` -> // `mergeFileConfig` treats a missing file as a no-op (`os.ErrNotExist` -> diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 328755acea..efe8bda00b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -157,13 +157,34 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * existing test surface — can stay a plain synchronous resolver; this is an * optional, rarely-configured field, not worth threading Effect dependencies * through `legacyStatusValues`/`status.handler.ts` for. + * + * Error wording matches Go's two `Validate` failure branches exactly + * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode + * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ + * `JSON.parse`'s raw Node error text through unwrapped. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { const absolutePath = isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); - const contents = readFileSync(absolutePath, "utf8"); - const jwks = decodeLegacyJwks(JSON.parse(contents)); + + let contents: string; + try { + contents = readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new Error( + `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + let jwks: ReadonlyArray; + try { + jwks = decodeLegacyJwks(JSON.parse(contents)); + } catch (cause) { + throw new Error( + `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } return jwks[0]; } diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 972e9bd595..36c6d71f5f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -261,9 +261,21 @@ describe("legacyResolveLocalConfigValues", () => { }); }); - it("throws when the signing keys file does not exist", () => { + it("throws a Go-worded error when the signing keys file does not exist", () => { const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("throws a Go-worded error when the signing keys file is malformed JSON", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to decode signing keys: ", + ); }); it("throws when the first key uses an unsupported algorithm", () => { From 7574db6ae7c0943ecc2d5edba170d0bef905965e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:24 +0100 Subject: [PATCH 13/22] fix(cli): preserve real Docker error text in status/stop container inspection (ci: e2e shard 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's assertContainerHealthy never special-cases a missing container — it wraps whatever ContainerInspect returns, so the real daemon error text ("No such container: ...") flows through. The TS port collapsed that case into a hardcoded "no such container" string via an "absent" sentinel, discarding the real text. Separately, legacyInspectContainerState's Effect.all([exitCode, stdout, stderr]) ran sequentially by default, awaiting exitCode (Node's "exit" event) before ever subscribing to the stdout/stderr streams. Node's "exit" can fire before a fast process's stdio pipes are drained, so the real Docker CLI's stderr was silently lost in the real subprocess environment even after removing the hardcoded string — reproduced against a real docker CLI subprocess, confirmed via runParity's stderr comparison in apps/cli-e2e. Same fix applied to the two other call sites in this file that share the pattern (legacyListContainersByLabel, legacyListVolumesByLabel). --- .../legacy/commands/status/status.handler.ts | 9 +-- .../status/status.integration.test.ts | 13 +++- .../legacy/shared/legacy-docker-lifecycle.ts | 64 +++++++++++-------- .../legacy-docker-lifecycle.unit.test.ts | 32 ++++++---- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 6c4921d8bd..06d1030496 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -130,17 +130,12 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // container fails `ContainerInspect` itself, which surfaces as the generic // inspect error (status.go:147-150), not the "not running" branch (which // only applies to a present-but-stopped container, status.go:150-151). + // `legacyInspectContainerState` mirrors that: a missing container is just + // another non-zero exit, mapped below with the real Docker stderr text. if (!flags.ignoreHealthCheck) { const state = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), ); - if (state === "absent") { - return yield* Effect.fail( - new LegacyStatusDbInspectError({ - message: "failed to inspect container health: no such container", - }), - ); - } if (!state.running) { return yield* Effect.fail( new LegacyStatusDbNotRunningError({ diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index fedccad54b..8f5174a557 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -418,18 +418,25 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when the db container is absent", () => { + it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it wraps + // whatever `ContainerInspect` returns (`status.go:148-149`), so the real + // Docker stderr must flow through rather than a hardcoded TS string. const { layer } = setup({ route: defaultRoute({ dbInspectExitCode: 1, - dbInspectStderr: ["Error: No such container: x"], + dbInspectStderr: ["Error response from daemon: No such container: x"], }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbInspectError"); + expect(serialized).toContain( + "failed to inspect container health: Error response from daemon: No such container: x", + ); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index 2a89464e10..985410723f 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -40,10 +40,6 @@ function splitNonEmptyLines(text: string): ReadonlyArray { .filter((line) => line.length > 0); } -function isMissingContainerError(stderr: string): boolean { - return stderr.toLowerCase().includes("no such container"); -} - /** * Go's `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` * (`docker.go:99-104`, `status.go:126-131`) via `docker ps --filter @@ -81,11 +77,19 @@ export const legacyListContainersByLabel = ( }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic: sequential `Effect.all` would + // await `exitCode` (resolved by Node's "exit" event) before subscribing + // to `stdout`/`stderr` at all. Node's "exit" can fire before a fast + // process's stdio pipes are drained, so a late subscriber sees an + // already-ended, empty stream instead of the buffered bytes. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleListError({ message: "failed to list containers" }), ), @@ -108,10 +112,11 @@ export const legacyListContainersByLabel = ( /** * Go's `Docker.ContainerInspect(ctx, containerId)` (`docker.go:148`, * `status.go:148-155`) via `docker container inspect --format - * {{json .State}}`. A "no such container" stderr resolves to the literal - * `"absent"`, mirroring `errdefs.IsNotFound(err)` — every other non-zero exit - * propagates as `LegacyDockerLifecycleInspectError`, matching Go's - * `assertContainerHealthy`, which does not special-case any other inspect failure. + * {{json .State}}`. Go's `assertContainerHealthy` does not special-case a + * missing container — it wraps whatever error `ContainerInspect` returns + * (`status.go:148-149`), so every non-zero exit, including "no such + * container", propagates as `LegacyDockerLifecycleInspectError` carrying the + * real Docker stderr text. */ export const legacyInspectContainerState = (spawner: Spawner, containerId: string) => Effect.scoped( @@ -132,11 +137,16 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleInspectError({ @@ -146,9 +156,6 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin ); if (exitCode !== 0) { const message = stderr.trim(); - if (isMissingContainerError(message)) { - return "absent" as const; - } return yield* Effect.fail( new LegacyDockerLifecycleInspectError({ message: @@ -217,11 +224,16 @@ export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: stri }), ), ); - const [exitCode, stdout, stderr] = yield* Effect.all([ - child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stdout), - collectByteStream(child.stderr), - ]).pipe( + // Concurrency is required, not cosmetic — see the matching comment in + // `legacyListContainersByLabel` above. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectByteStream(child.stdout), + collectByteStream(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( Effect.mapError( () => new LegacyDockerLifecycleListError({ message: "failed to list volumes" }), ), diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 29704048c4..39505f964d 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -200,17 +200,27 @@ describe("legacyInspectContainerState", () => { ); }); - it.live('resolves to "absent" when the container does not exist', () => { - const mock = mockSpawner({ - exitCode: 1, - stderr: "Error: No such container: supabase_db_my-app\n", - }); - return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( - Effect.map((state) => { - expect(state).toBe("absent"); - }), - ); - }); + it.live( + "fails with LegacyDockerLifecycleInspectError, preserving the real stderr, when the container does not exist", + () => { + // Go's `assertContainerHealthy` never special-cases "not found" — it + // wraps whatever `ContainerInspect` returns (`status.go:148-149`), so a + // missing container is just another non-zero exit here too. + const mock = mockSpawner({ + exitCode: 1, + stderr: "Error response from daemon: No such container: supabase_db_my-app\n", + }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerLifecycleInspectError); + expect(error.message).toBe( + "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", + ); + }), + ); + }, + ); it.live("fails with LegacyDockerLifecycleInspectError on any other inspect failure", () => { const mock = mockSpawner({ exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }); From 1cc8deba699effe6caa6cb3c653bc6357f413eaa Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:32 +0100 Subject: [PATCH 14/22] fix(cli): print stop's "Stopping containers..." unconditionally (ci: e2e shard 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go prints this line via an unconditional, immediate fmt.Fprintln before any Docker call runs (docker.go:97), routed straight to stdout in non-interactive mode (tea.go's fakeProgram). The TS port gated it behind the shared output.task spinner's 200ms debounce, so the line was silently dropped whenever the underlying Docker calls resolved faster than that threshold — exactly what happens against the mocked/replayed Docker CLI in the e2e harness. Printing it directly via output.raw removes the race entirely. --- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 3 +- .../src/legacy/commands/stop/stop.handler.ts | 25 +++++------ .../commands/stop/stop.integration.test.ts | 45 ++++++++++--------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 4c918076e5..2711306a5e 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -70,7 +70,8 @@ This is a harmless, documented divergence: Go would reject an unknown `-o` flag ### `--output-format text` (Go CLI compatible) -- stderr (transient): `Stopping containers...` +- stdout: `Stopping containers...` (printed unconditionally before any Docker call, + matching Go's `fmt.Fprintln` — see `docker.go:97`) - stdout: `Stopped supabase local development setup.` (`supabase` rendered in Aqua/cyan when the output stream is a TTY, plain otherwise) - stderr (conditional): when any Docker volume still carries the project's diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 0d2ef56339..a660d8fe8f 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -108,16 +108,17 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const deleteVolumes = flags.noBackup; const filterValue = legacyCliProjectFilterValue(searchProjectIdFilter); - // Captured (not discarded) so it can be `.fail()`ed or `.clear()`ed below, - // matching the project's established `output.task` usage pattern - // (apps/cli/CLAUDE.md's "always wrap API calls in output.task"). In - // non-interactive/CI runs the spinner never renders, but `.fail()`/`.clear()` - // still resolve cleanly — a discarded handle would otherwise leave a spinner - // that's started but never stopped. A single `Effect.tapError` around the - // whole list/stop/prune sequence (rather than one per step) fails the same - // task on any error without repeating the same branch at every call site. - const stopping = - output.format === "text" ? yield* output.task("Stopping containers...") : undefined; + // Go prints this line unconditionally and immediately — `docker.go:97`'s + // `fmt.Fprintln(w, "Stopping containers...")`, where `w` is a + // `StatusWriter` that `fmt.Println`s straight to stdout in non-interactive + // mode (`tea.go:59-60,87-90`) before any Docker call runs. The debounced + // `output.task` spinner used elsewhere in this codebase gates its message + // behind a delay, which drops this line whenever the underlying calls + // resolve faster than that threshold — exactly what happens against the + // mocked/replayed Docker CLI. Print it directly so it always appears. + if (output.format === "text") { + yield* output.raw("Stopping containers...\n"); + } yield* Effect.gen(function* () { const containerIds = yield* legacyListContainersByLabel(spawner, { @@ -212,9 +213,7 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF new LegacyStopNetworkPruneError({ message: "failed to prune networks" }), ); } - }).pipe(Effect.tapError(() => stopping?.fail() ?? Effect.void)); - - yield* stopping?.clear() ?? Effect.void; + }); if (output.format === "text") { // Written to stdout (no stream arg): `legacyAqua` must target stdout's own diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index edbd1031d2..4c3ddd3264 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -211,6 +211,7 @@ describe("legacy stop integration", () => { ["stop", "c1"], ["stop", "c2"], ]); + expect(out.stdoutText).toContain("Stopping containers..."); expect(out.stdoutText).toContain("Stopped"); expect(out.stdoutText).toContain("local development setup."); expect(out.stderrText).toContain( @@ -471,26 +472,30 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails cleanly in json mode without a text-mode spinner to dismiss", () => { - // No `output.task` handle exists outside text mode — this exercises that - // the failure path's `stopping?.fail() ?? Effect.void` no-ops correctly. - const { layer } = setup({ - format: "json", - configuredProjectId: "demo", - route: (args) => { - if (args[0] === "ps") return { stdout: ["c1"] }; - if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; - return { exitCode: 0 }; - }, - }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyStop(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); - } - }).pipe(Effect.provide(layer)); - }); + it.live( + "fails the same way in json mode, where 'Stopping containers...' is never printed", + () => { + // The `output.format === "text"` gate around the "Stopping containers..." + // line means json mode skips it entirely; this exercises that the + // list/stop/prune failure path is unaffected by that gate. + const { layer } = setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + } + }).pipe(Effect.provide(layer)); + }, + ); it.live("fails when container prune errors", () => { const { layer } = setup({ From ad5c212b8a6ad81b57d25707e2dd2ae8a1a49726 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 15:49:40 +0100 Subject: [PATCH 15/22] test(cli-test-helpers): ignore Docker client negotiation noise in parity request logs (ci: e2e shard 1/3) stop/status parity comparisons were failing on request-log mismatches that reflect nothing about CLI behavior: the real docker CLI issues a fresh HEAD /_ping handshake before every subprocess invocation (Go's SDK pings once per command via a persistent client), and negotiates its own API version segment into the URL path (/v1.51/ vs /v1.53/) based on the installed docker CLI/daemon, not anything the command controls. Strip both before comparing so parity reflects the actual Docker operations performed rather than client plumbing, mirroring the equivalent normalization already used for fixture matching in apps/cli-e2e/src/server/placeholder.ts. --- packages/cli-test-helpers/src/parity.ts | 32 ++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/cli-test-helpers/src/parity.ts b/packages/cli-test-helpers/src/parity.ts index 9b0aae97c1..a52401f85f 100644 --- a/packages/cli-test-helpers/src/parity.ts +++ b/packages/cli-test-helpers/src/parity.ts @@ -214,6 +214,24 @@ function snapshotChangedFiles(dir: string): FileRecord[] { // RunResult collection // --------------------------------------------------------------------------- +/** + * Docker Engine API calls carry two client-negotiation artifacts that reflect + * nothing about CLI behavior: an `HEAD /_ping` handshake the real `docker` CLI + * issues before every subprocess invocation (Go's SDK pings once per command + * via a persistent client; ts-legacy shells out to `docker` per operation, so + * it pings once per operation), and a negotiated API version segment in the + * URL path (`/v1.51/...` vs `/v1.53/...`) that reflects the installed docker + * CLI/daemon version, not anything the command controls. Strip both so parity + * comparisons reflect actual Docker operations (list, stop, prune, inspect, + * with their filters) rather than client plumbing. Mirrors the equivalent + * normalization in `apps/cli-e2e/src/server/placeholder.ts`'s + * `normalizeUrlPath`, applied here to the request-log comparison instead of + * fixture matching. + */ +function normalizeDockerRequestPath(pathname: string): string { + return pathname.replace(/\/v1\.\d+(\/|$)/g, "/__DOCKER_VERSION__$1"); +} + async function fetchRequestLog(apiUrl: string): Promise { const res = await fetch(`${apiUrl}/_ctrl/requests`); const raw = (await res.json()) as Array<{ @@ -222,12 +240,14 @@ async function fetchRequestLog(apiUrl: string): Promise { query: Record; body: unknown; }>; - return raw.map(({ method, pathname, query, body }) => ({ - method, - pathname, - query, - body, - })); + return raw + .filter(({ method, pathname }) => !(method === "HEAD" && pathname === "/_ping")) + .map(({ method, pathname, query, body }) => ({ + method, + pathname: normalizeDockerRequestPath(pathname), + query, + body, + })); } async function collectRunResult( From bd2fee925700865584a4bdf8ae7018bd0b3bb2c8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 16:08:51 +0100 Subject: [PATCH 16/22] fix(cli): apply SUPABASE_AUTH_SIGNING_KEYS_PATH env override to status (review: PRRT_kwDOErm0O86N7ctR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Viper binds SetEnvPrefix("SUPABASE") + AutomaticEnv() over every config field via UnmarshalExact's struct walk (pkg/config/config.go:531-535, 698-705), including the plain-string Auth.SigningKeysPath field (pkg/config/auth.go:164) — not just the 5 auth fields this module already wrapped with envOverride. Load() resolves that override before Validate() opens and parses the JWK file (config.go:735-745, 1059-1062), so an env-only SUPABASE_AUTH_SIGNING_KEYS_PATH was silently ignored and status fell back to HMAC-signed keys the running Auth service would reject. --- .../shared/legacy-local-config-values.ts | 7 ++- .../legacy-local-config-values.unit.test.ts | 43 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index efe8bda00b..1d8c9ed2b9 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -92,7 +92,7 @@ const MIN_JWT_SECRET_LENGTH = 16; * Go's `Config.Load` binds Viper with `SetEnvPrefix("SUPABASE")` + * `AutomaticEnv()` + a `.`→`_` key replacer (`pkg/config/config.go:529-535`), * so any config field can be overridden by a `SUPABASE_` env var — - * this resolves it for exactly the 5 auth fields this module reads, at the + * this resolves it for exactly the 6 auth fields this module reads, at the * same higher-than-config.toml precedence Viper gives env vars. An empty env * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never * enabled in `config.go`). @@ -202,7 +202,10 @@ export function legacyResolveLocalConfigValues( const jwtSecret = resolveJwtSecret( envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), ); - const signingKeysPath = config.auth.signing_keys_path; + const signingKeysPath = envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + ); const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 36c6d71f5f..e7c2b46315 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -27,6 +27,12 @@ function generateRsaJwk(): Record { return { ...jwk, alg: "RS256", kid: "test-rsa-kid" }; } +function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); +} + describe("legacyResolveLocalConfigValues", () => { it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); @@ -136,6 +142,8 @@ describe("legacyResolveLocalConfigValues", () => { }); describe("SUPABASE_AUTH_* env overrides", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() // (pkg/config/config.go:529-535) — env vars take precedence over config.toml. const ENV_KEYS = [ @@ -144,6 +152,7 @@ describe("legacyResolveLocalConfigValues", () => { "SUPABASE_AUTH_SECRET_KEY", "SUPABASE_AUTH_ANON_KEY", "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", ] as const; afterEach(() => { @@ -193,17 +202,39 @@ describe("legacyResolveLocalConfigValues", () => { LegacyInvalidJwtSecretError, ); }); + + it("overrides signing_keys_path even when config.toml doesn't set one", async () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig(); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = await importJWK(publicJwk, "RS256"); + const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + }); + + it("prefers an env-provided signing_keys_path over config.toml's", () => { + const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; + const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; + writeSigningKeys(tempRoot.current, [envJwk]); + const supabaseDir = join(tempRoot.current, "supabase"); + writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; + const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const [header] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }); }); describe("auth.signing_keys_path (asymmetric JWT signing)", () => { const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); - function writeSigningKeys(workdir: string, jwks: ReadonlyArray>) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); - } - it("signs anon/service_role with the first RS256 key in the file", async () => { const jwk = generateRsaJwk(); writeSigningKeys(tempRoot.current, [jwk]); From fb101635cd85d854d21dda0bc678aec45d74e8d3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 2 Jul 2026 16:09:09 +0100 Subject: [PATCH 17/22] fix(cli): resolve SUPABASE_PROJECT_ID from supabase/.env for stop and status (review: PRRT_kwDOErm0O86N7ctY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load runs loadNestedEnv (supabase/.env and .env.local via godotenv.Load, which never overrides an already-set var) before loadFromFile wires up Viper's AutomaticEnv (pkg/config/config.go:735-745, 528-535) — so an env-file-only SUPABASE_PROJECT_ID overrides config.toml's project_id too, not just an ambient shell export. Both handlers only read process.env directly, missing that middle step: an env-named stack could be left running by stop, or status could target the wrong container. @supabase/config's loadProjectEnvironment already implements the same "ambient wins over .env/.env.local" layering (used today only for env() interpolation inside config.toml), so both handlers now resolve SUPABASE_PROJECT_ID through it instead of process.env directly, and pass the same resolved environment into loadProjectConfig so the .env files aren't parsed twice. Falls back to process.env directly when no supabase/config.toml exists anywhere (loadProjectEnvironment resolves to null in that case), preserving the existing ambient-only behavior for that scenario. The identical gap in LegacyCliConfig.projectId (used by several other already-ported commands) is out of scope here and left for a follow-up. --- .../legacy/commands/status/status.handler.ts | 23 +++++++-- .../status/status.integration.test.ts | 42 ++++++++++++++++ .../src/legacy/commands/stop/stop.handler.ts | 31 ++++++++++-- .../commands/stop/stop.integration.test.ts | 48 +++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 06d1030496..f10317706b 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig, ProjectConfigSchema } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, Option, Schema } from "effect"; @@ -101,7 +101,18 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // Mirror that by decoding an empty document through the schema for its // defaults (matching `packages/config/src/functions-manifest.ts`'s // `decodeProjectConfig({})` pattern) instead of failing. - const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv ?? undefined, + }).pipe( Effect.mapError( (cause) => new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), @@ -115,10 +126,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // (`pkg/config/config.go:938-944`) — every reader, including the Docker // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same // sanitized string, so `status` must filter on it too (see - // `legacyCliProjectFilterValue`'s doc comment). + // `legacyCliProjectFilterValue`'s doc comment). "env" is Go's + // post-`loadNestedEnv` value (`supabase/.env`/`.env.local`, ambient wins) — + // see `stop.handler.ts`'s `resolveSearchProjectIdFilter` doc comment for + // the full precedence chain and why `loadProjectEnvironment` is used here + // instead of reading `process.env` directly. const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], + projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, cliConfig.workdir, ), diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 8f5174a557..44b4b9a368 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -357,6 +357,48 @@ describe("legacy status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("env-file-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("env-file-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("ambient-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("ambient-project")); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + it.live("fails when both docker and podman are missing", () => { // Neither container runtime can be spawned at all — distinct from a spawned // process exiting non-zero (covered by the malformed/unhealthy scenarios diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index a660d8fe8f..43332e8734 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,4 +1,4 @@ -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, Option, Result } from "effect"; @@ -37,6 +37,19 @@ import { * config.toml; otherwise `flags.LoadConfig` reads config.toml and * `Config.ProjectId` (env → toml → workdir basename) is used. * + * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell + * environment: `Config.Load` loads `supabase/.env`/`.env.local` into the + * process env via `godotenv.Load` (`pkg/config/config.go:735-738`; godotenv + * never overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value + * overrides config.toml too, not only an ambient shell export. + * `loadProjectEnvironment` already implements that exact "ambient wins over + * .env/.env.local" layering, so it's used here instead of reading + * `process.env` directly. It resolves to `null` when no `supabase/` + * config file exists anywhere up the tree, which would otherwise also drop + * the ambient-only case — falling back to `process.env` directly covers that + * gap without duplicating the "no config.toml" path's own error handling. + * * The config/env-derived (default) branch is sanitized with * {@link legacySanitizeProjectId} before it's used as a filter value, * matching Go's `Config.Validate` sanitizing the `Config.ProjectId` @@ -59,18 +72,30 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject return flags.projectId.value; } + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a // malformed file (`loadProjectConfig` failing rather than returning // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. - const loaded = yield* loadProjectConfig(cliConfig.workdir).pipe( + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv ?? undefined, + }).pipe( Effect.mapError( (cause) => new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); const resolved = legacyResolveLocalProjectId( - process.env["SUPABASE_PROJECT_ID"], + projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 4c3ddd3264..d97bc8afde 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -33,6 +33,12 @@ function writeConfig(workdir: string, projectId: string) { writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); } +function writeEnvFile(workdir: string, fileName: ".env" | ".env.local", contents: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, fileName), contents); +} + interface SpawnRecord { readonly command: string; readonly args: ReadonlyArray; @@ -346,6 +352,48 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before loadFromFile's AutomaticEnv reads SUPABASE_PROJECT_ID + // (pkg/config/config.go:735-738) — an env-file-only value overrides + // config.toml's project_id too, not just an ambient shell export. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=env-file-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); + process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=ambient-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), + ); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { From 23951871d37dc28c67b408f0988c49df13c11c3f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 07:59:33 +0100 Subject: [PATCH 18/22] fix(cli): omit Docker-only --all from Podman's volume prune in stop (review: PRRT_kwDOErm0O86N8liv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No released Podman `volume prune` (checked v4.3 through the current v5.7) accepts `--all` — only `--filter`/`--force`/`--help` — so the Podman-CLI fallback `spawnContainerCli` already provides for Docker-less hosts would hard-fail after containers are already stopped. Podman already prunes every unused volume by default, so dropping `--all` on that path is lossless. Not a Go-parity gap (Go talks to the Docker Engine API directly and never shells out to a `docker`/`podman` binary), but a real bug in this port's own Podman fallback, confirmed via the existing "falls back to podman" coverage in stop.integration.test.ts. --- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 12 ++++++--- .../src/legacy/commands/stop/stop.handler.ts | 23 ++++++++++------ .../commands/stop/stop.integration.test.ts | 26 +++++++++++++++++++ .../src/legacy/shared/legacy-container-cli.ts | 9 ++++++- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 2711306a5e..0b2528fb2f 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -102,10 +102,14 @@ Same payload as `json`, delivered as a `result` NDJSON event. regardless of `--backup`. The TS port matches this exactly: `deleteVolumes = flags.noBackup`. `--backup=false` alone does **not** delete volumes; only `--no-backup` does. -- Volume prune always passes `--all`. Go gates that flag on Docker engine >= 1.42 - (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips the - version check and always passes `--all` because every currently supported Docker - version is far past 1.42. +- Volume prune always passes `--all` on Docker. Go gates that flag on Docker engine >= + 1.42 (`docker.go:120-124`, since named-volume pruning requires it); the TS port skips + the version check and always passes `--all` because every currently supported Docker + version is far past 1.42. On the Podman fallback, `--all` is omitted instead: no + released Podman `volume prune` (checked v4.3 through the current v5.7) accepts that + flag, and Podman already prunes every unused volume by default, so dropping it there + is lossless. Podman itself is a TS-only fallback (Go never shells out to a + `docker`/`podman` binary), so this has no Go-parity implication either way. - Containers are stopped concurrently (`Effect.all(..., { concurrency: "unbounded" })`), mirroring Go's `WaitAll` goroutine fan-out. Every container's failure is checked before failing the command (rather than stopping at the first failure), matching Go's diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 43332e8734..5c3acf1254 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -197,14 +197,21 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // Go gates the `--all` filter arg on Docker engine >= 1.42 (`docker.go:120-124`). // All currently supported Docker versions are well past 1.42, so the TS port // always passes `--all` — documented divergence, see SIDE_EFFECTS.md Notes. - const volumePruneExitCode = yield* containerCliExitCode(spawner, [ - "volume", - "prune", - "--force", - "--all", - "--filter", - `label=${filterValue}`, - ]).pipe( + // + // Podman is a Docker-CLI-compatible fallback this port adds, not something + // Go itself has (Go talks to the Docker Engine API directly, never a + // `docker`/`podman` binary), so there's no Go behavior to match here — but + // `--all` isn't a real flag on any released Podman `volume prune` (only + // `--filter`/`--force`/`--help`, checked v4.3 through the current v5.7; + // `--all` only exists in unreleased dev docs), so it hard-fails on a real + // Podman-only host. Podman already prunes every unused volume by default, + // so omitting `--all` on the Podman fallback is a lossless fix. + const volumePruneExitCode = yield* containerCliExitCode( + spawner, + ["volume", "prune", "--force", "--all", "--filter", `label=${filterValue}`], + undefined, + ["volume", "prune", "--force", "--filter", `label=${filterValue}`], + ).pipe( Effect.mapError( (cause) => new LegacyStopVolumePruneError({ diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index d97bc8afde..2f6cb42f02 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -630,6 +630,32 @@ describe("legacy stop integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("omits --all from podman's volume prune (not a real Podman flag)", () => { + // No released Podman `volume prune` accepts `--all` (only `--filter`/`--force`/ + // `--help`), so passing Docker's `--all` argv straight through to the Podman + // fallback would hard-fail after containers are already stopped. Podman prunes + // every unused volume by default, so dropping `--all` there is lossless. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute(), + dockerMissing: true, + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePruneCalls = child.spawned.filter( + (s) => s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePruneCalls.at(-1)?.command).toBe("podman"); + expect(volumePruneCalls.at(-1)?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a machine result in json mode without spinner text", () => { const { layer, out } = setup({ format: "json", diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 20a7a20654..e20a43c078 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -72,18 +72,25 @@ export const spawnContainerCli = ( /** * Run a container-CLI command and resolve to its exit code, mirroring the * spawner's `exitCode` convenience for callers that only need the status. + * + * `podmanArgs` lets a caller pass different argv to the Podman fallback than to + * Docker, for the rare case where the two aren't drop-in compatible on a given + * subcommand (e.g. `volume prune --all` — Docker-only, see + * `stop.handler.ts`'s volume-prune call). Defaults to reusing `args` unchanged, + * which is correct for every other call site. */ export const containerCliExitCode = ( spawner: Spawner, args: ReadonlyArray, options?: ChildProcess.CommandOptions, + podmanArgs?: ReadonlyArray, ) => spawner .exitCode(ChildProcess.make("docker", args, options)) .pipe( Effect.catch(() => spawner - .exitCode(ChildProcess.make("podman", args, options)) + .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) .pipe( Effect.catch(() => Effect.fail( From c0d1e5d238bd7707b81958b5836e9b93f4050e91 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 08:00:07 +0100 Subject: [PATCH 19/22] fix(cli): resolve project-root/SUPABASE_ENV dotenv values in stop/status (review: PRRT_kwDOErm0O86N8lio, PRRT_kwDOErm0O86N8lik) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load runs loadNestedEnv (pkg/config/config.go:1169-1207) before Viper's AutomaticEnv reads any SUPABASE_* override: it loads not just supabase/.env(.local) but also project-root and SUPABASE_ENV-selected dotenv files (.env..local, .env.local, .env., .env), via godotenv.Load, which never overrides an already-set var. `loadProjectEnvironment` only covered the supabase/-dir, plain .env/.env.local half of that. Add `legacyResolveProjectEnvironmentValues` (apps/cli/src/legacy/shared/) to fill the gap locally for stop/status rather than extending `loadProjectEnvironment` itself, which is shared infrastructure used well beyond legacy/ (the next/ command tree, secrets set) — out of scope for a stop/status port, consistent with this PR's existing precedent (deaf1e66) of flagging genuinely cross-cutting @supabase/config gaps rather than folding them in. Wire the enriched map into both: - stop/status's SUPABASE_PROJECT_ID resolution, so a value that only lives in a project-root or SUPABASE_ENV-selected file is no longer missed. - status's SUPABASE_AUTH_* key overrides (legacyResolveLocalConfigValues), which previously only checked ambient process.env, missing the same class of dotenv-only values for JWT secret, signing keys path, and the publishable/secret/anon/service-role key overrides. --- .../legacy/commands/status/status.handler.ts | 26 ++-- .../status/status.integration.test.ts | 37 ++++++ .../legacy/commands/status/status.values.ts | 13 +- .../src/legacy/commands/stop/stop.handler.ts | 25 ++-- .../commands/stop/stop.integration.test.ts | 20 +++ .../shared/legacy-local-config-values.ts | 29 +++-- .../shared/legacy-project-environment.ts | 114 +++++++++++++++++ .../legacy-project-environment.unit.test.ts | 115 ++++++++++++++++++ 8 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.ts create mode 100644 apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index f10317706b..eec5b04ecb 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -25,6 +25,7 @@ import { encodeYaml, } from "../../shared/legacy-go-output.encoders.ts"; import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; import { LegacyStatusConfigLoadError, @@ -120,20 +121,24 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Used below both for `SUPABASE_PROJECT_ID` and + // for the `SUPABASE_AUTH_*` overrides `legacyResolveStatusState` reads. + const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); + // 2. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time // (`pkg/config/config.go:938-944`) — every reader, including the Docker // LABEL `start` writes (`internal/utils/docker.go:375`), sees that same // sanitized string, so `status` must filter on it too (see - // `legacyCliProjectFilterValue`'s doc comment). "env" is Go's - // post-`loadNestedEnv` value (`supabase/.env`/`.env.local`, ambient wins) — - // see `stop.handler.ts`'s `resolveSearchProjectIdFilter` doc comment for - // the full precedence chain and why `loadProjectEnvironment` is used here - // instead of reading `process.env` directly. + // `legacyCliProjectFilterValue`'s doc comment). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], config.project_id, cliConfig.workdir, ), @@ -202,7 +207,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // service_role JWTs signed only once per invocation, not twice. const state = yield* Effect.try({ try: () => - legacyResolveStatusState(config, containerIds, hostname, excluded, cliConfig.workdir), + legacyResolveStatusState( + config, + containerIds, + hostname, + excluded, + cliConfig.workdir, + projectEnvValues, + ), catch: (cause) => new LegacyStatusInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index 44b4b9a368..e6cdc712df 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -399,6 +399,43 @@ describe("legacy status integration", () => { ); }); + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const { layer, child } = setup({ + configContents: 'project_id = "toml-project"\n', + route: defaultRoute({ runningNames: legacyServiceContainerIds("root-env-project") }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("root-env-project")); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { + // Go's Config.Load runs loadNestedEnv (supabase/.env(.local) via godotenv) + // before AutomaticEnv reads SUPABASE_AUTH_JWT_SECRET (config.go:735-738) — + // a dotenv-file-only value must be visible here too, not just an ambient + // shell export (see the sibling "-o env" ambient test above). + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); + const { layer, out } = setup({ + goOutput: Option.some("env"), + configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when both docker and podman are missing", () => { // Neither container runtime can be spawned at all — distinct from a spawned // process exiting non-zero (covered by the malformed/unhealthy scenarios diff --git a/apps/cli/src/legacy/commands/status/status.values.ts b/apps/cli/src/legacy/commands/status/status.values.ts index f8165f4790..78f04770f2 100644 --- a/apps/cli/src/legacy/commands/status/status.values.ts +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -249,8 +249,9 @@ export function legacyResolveStatusState( hostname: string, excluded: ReadonlyArray, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyStatusState { - const local = legacyResolveLocalConfigValues(config, hostname, workdir); + const local = legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues); const isExcluded = (id: string) => excluded.includes(id); const kongEnabled = @@ -355,7 +356,15 @@ export function legacyStatusValues( excluded: ReadonlyArray, overrides: ReadonlyMap, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyStatusValuesResult { - const state = legacyResolveStatusState(config, containerIds, hostname, excluded, workdir); + const state = legacyResolveStatusState( + config, + containerIds, + hostname, + excluded, + workdir, + projectEnvValues, + ); return legacyStatusValuesFromState(state, overrides); } diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 5c3acf1254..0b7def8104 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -19,6 +19,7 @@ import { legacyListContainersByLabel, legacyListVolumesByLabel, } from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; import { LegacyStopConfigLoadError, @@ -38,17 +39,20 @@ import { * `Config.ProjectId` (env → toml → workdir basename) is used. * * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell - * environment: `Config.Load` loads `supabase/.env`/`.env.local` into the - * process env via `godotenv.Load` (`pkg/config/config.go:735-738`; godotenv - * never overrides an already-set var) *before* Viper's `AutomaticEnv` reads + * environment: `Config.Load` loads `supabase/.env`/`.env.local` *and* + * project-root/`SUPABASE_ENV`-selected dotenv files into the process env via + * `godotenv.Load` (`pkg/config/config.go:735-738,1169-1207`; godotenv never + * overrides an already-set var) *before* Viper's `AutomaticEnv` reads * `SUPABASE_PROJECT_ID` (`config.go:534-535`) — so an env-file-only value * overrides config.toml too, not only an ambient shell export. - * `loadProjectEnvironment` already implements that exact "ambient wins over - * .env/.env.local" layering, so it's used here instead of reading - * `process.env` directly. It resolves to `null` when no `supabase/` - * config file exists anywhere up the tree, which would otherwise also drop - * the ambient-only case — falling back to `process.env` directly covers that - * gap without duplicating the "no config.toml" path's own error handling. + * `legacyResolveProjectEnvironmentValues` implements that full precedence + * chain (see its doc comment) on top of `loadProjectEnvironment`'s + * `supabase/`-dir-only result, so it's used here instead of reading + * `process.env` directly. Both resolve to `undefined`/`null` when no + * `supabase/` config file exists anywhere up the tree, which would otherwise + * also drop the ambient-only case — falling back to `process.env` directly + * covers that gap without duplicating the "no config.toml" path's own error + * handling. * * The config/env-derived (default) branch is sanitized with * {@link legacySanitizeProjectId} before it's used as a filter value, @@ -94,8 +98,9 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); + const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); const resolved = legacyResolveLocalProjectId( - projectEnv?.values["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, cliConfig.workdir, ); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 2f6cb42f02..9a66847fdd 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -394,6 +394,26 @@ describe("legacy stop integration", () => { ); }); + it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + // Go's loadNestedEnv walks past supabase/ one more level, to the project + // root/workdir (pkg/config/config.go:1169-1190) — a project-root-only + // dotenv value must override config.toml too, not just supabase/.env. + const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + return Effect.gen(function* () { + yield* legacyStop(flags()); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=root-env-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --project-id together with --all", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 1d8c9ed2b9..1ec3668695 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -96,9 +96,22 @@ const MIN_JWT_SECRET_LENGTH = 16; * same higher-than-config.toml precedence Viper gives env vars. An empty env * var is treated as unset, matching Viper's default (`AllowEmptyEnv` is never * enabled in `config.go`). + * + * Viper's `AutomaticEnv` binding runs AFTER `Config.Load`'s `loadNestedEnv` + * (`config.go:735-738`), which loads `supabase/.env`(.local) and project-root + * dotenv files into the process env before any `SUPABASE_*` var is read + * (`config.go:1169-1207`) — so a value that lives only in one of those files, + * not the ambient shell, must still be visible here. `projectEnvValues` is + * that already-resolved map (see `legacyResolveProjectEnvironmentValues`); + * falling back to `process.env` covers the "no `supabase/` project found" + * case, where `projectEnvValues` is `undefined`. */ -function envOverride(name: string, configured: string | undefined): string | undefined { - const value = process.env[name]; +function envOverride( + name: string, + configured: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const value = projectEnvValues?.[name] ?? process.env[name]; return value !== undefined && value.length > 0 ? value : configured; } @@ -197,14 +210,16 @@ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, workdir: string, + projectEnvValues: Readonly> | undefined = undefined, ): LegacyLocalConfigValues { const apiExternalUrl = legacyResolveApiExternalUrl(config.api, hostname); const jwtSecret = resolveJwtSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret), + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), ); const signingKeysPath = envOverride( "SUPABASE_AUTH_SIGNING_KEYS_PATH", config.auth.signing_keys_path, + projectEnvValues, ); const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 @@ -221,22 +236,22 @@ export function legacyResolveLocalConfigValues( mailpitUrl: `http://${hostname}:${config.local_smtp.port}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${config.db.port}/postgres`, publishableKey: resolveOpaqueKey( - envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key), + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), defaultPublishableKey, ), secretKey: resolveOpaqueKey( - envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key), + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), defaultSecretKey, ), jwtSecret, anonKey: resolveSignedKey( - envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key), + envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), jwtSecret, signingKey, "anon", ), serviceRoleKey: resolveSignedKey( - envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key), + envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", config.auth.service_role_key, projectEnvValues), jwtSecret, signingKey, "service_role", diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts new file mode 100644 index 0000000000..f5c103a24f --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; + +/** + * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and Go's + * `loadNestedEnv` (`apps/cli-go/pkg/config/config.go:1169-1190`). Go's version + * walks not just `supabase/` but one directory further, up to the project + * root/workdir (the loop stops once `cwd == filepath.Dir(repoDir)`, i.e. after + * exactly two directories: `supabase/`, then its parent), and at each + * directory calls `loadDefaultEnv` (`config.go:1192-1207`), which loads dotenv + * files chosen by `SUPABASE_ENV` (empty/unset defaults to `"development"`, + * `config.go:1193-1195`): `.env..local`, `.env.local` (skipped when + * `env === "test"`), `.env.`, `.env` — via `godotenv.Load`, which only + * sets a key if it isn't already present in the process environment + * (`godotenv@v1.5.1/godotenv.go:184-204`, `overload: false`). Because + * `godotenv.Load` writes straight into the process env as it goes, the net + * precedence (highest first) is: ambient shell env > `supabase/`-dir dotenv + * files (`.local` variant before non-local, env-specific before bare `.env`) + * > project-root dotenv files (same internal order). + * + * `loadProjectEnvironment` only implements the `supabase/`-dir, plain + * `.env`/`.env.local` half of this (no project-root pass, no `SUPABASE_ENV` + * filename selection) — and it's shared infrastructure used well beyond + * `legacy/` (the `next/` command tree, `secrets set`), so extending its + * file-resolution semantics is out of scope for a `stop`/`status` port. + * Instead, this fills in the missing project-root + `SUPABASE_ENV`-selected + * files locally: `loadProjectEnvironment`'s already-resolved `values` (its + * ambient-wins-over-`supabase/.env`(.local) result) always takes precedence + * over anything discovered here, since it's already correct for the keys it + * knows about. + */ +function candidateDotenvFilenames(env: string): ReadonlyArray { + return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; +} + +/** + * Minimal `KEY=VALUE` dotenv reader, intentionally not reusing + * `@supabase/config`'s Effect-based `FileSystem` parser: this module stays a + * plain synchronous helper (like `legacy-local-config-values.ts`'s + * `loadFirstSigningKey`) since it only needs a handful of extra files read + * once per `stop`/`status` invocation. Quoting/escaping matches + * `packages/config/src/project.ts`'s `parseDotEnv` closely enough for the env + * vars this is used for (`SUPABASE_PROJECT_ID`, `SUPABASE_AUTH_*`), which + * never need the full dotenv spec (multiline values, `export` re-declares). + */ +function readDotEnvFile(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + + const contents = readFileSync(path, "utf8"); + const values: Record = {}; + + for (const rawLine of contents.split(/\r\n?|\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + + const match = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/.exec(line); + if (match === null) continue; + const key = match[1]; + if (key === undefined) continue; + + let value = (match[2] ?? "").trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) { + value = value.slice(1, -1); + if (quote === '"') { + value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); + } + } else { + const commentIndex = value.indexOf("#"); + if (commentIndex >= 0) value = value.slice(0, commentIndex).trim(); + } + + values[key] = value; + } + + return values; +} + +/** + * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` + * overrides (project id, auth fields) from — `projectEnv.values` (ambient + + * `supabase/.env`(.local), already correct) layered over the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover. + * Returns `undefined` when `projectEnv` is `null` (no `supabase/` project + * found), matching callers' existing "fall back to `process.env` directly" + * behavior. + */ +export function legacyResolveProjectEnvironmentValues( + projectEnv: ProjectEnvironment | null, +): Record | undefined { + if (projectEnv === null) return undefined; + + const env = process.env["SUPABASE_ENV"] || "development"; + const filenames = candidateDotenvFilenames(env); + const merged: Record = {}; + + // supabase/ dir first, then its parent (the project root) — matching Go's + // directory walk order. Within a directory, `godotenv.Load`'s "never + // override an already-set var" means first-processed-wins, so the plain + // merge below (skip keys already present) reproduces both orderings at once. + for (const dir of [projectEnv.paths.supabaseDir, projectEnv.paths.projectRoot]) { + for (const filename of filenames) { + const parsed = readDotEnvFile(join(dir, filename)); + if (parsed === undefined) continue; + for (const [key, value] of Object.entries(parsed)) { + if (!(key in merged)) merged[key] = value; + } + } + } + + return { ...merged, ...projectEnv.values }; +} diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts new file mode 100644 index 0000000000..0dc5c62571 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -0,0 +1,115 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; + +let root: string; +let supabaseDir: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-legacy-project-env-")); + supabaseDir = join(root, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env["SUPABASE_ENV"]; +}); + +function fakeProjectEnv(values: Record = {}): ProjectEnvironment { + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + envPath: join(supabaseDir, ".env"), + envLocalPath: join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +describe("legacyResolveProjectEnvironmentValues", () => { + it("returns undefined when no project was found", () => { + expect(legacyResolveProjectEnvironmentValues(null)).toBeUndefined(); + }); + + it("returns just the already-loaded values when no extra dotenv files exist", () => { + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); + expect(legacyResolveProjectEnvironmentValues(projectEnv)).toEqual({ + SUPABASE_PROJECT_ID: "from-loader", + }); + }); + + it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }); + + it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets already-resolved projectEnv.values win over anything discovered locally", () => { + // `projectEnv.values` already reflects loadProjectEnvironment's correct + // ambient-wins-over-supabase/.env(.local) result; a redundant root .env + // entry for the same key must never override it. + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("defaults SUPABASE_ENV to development when unset", () => { + writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }); + + it("selects the SUPABASE_ENV-named file over the bare .env file", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }); + + it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { + process.env["SUPABASE_ENV"] = "production"; + writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); + writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }); + + it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { + process.env["SUPABASE_ENV"] = "test"; + writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); + writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }); + + it("strips quotes the same way the shared dotenv parser does", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }); + + it("ignores blank lines and comments", () => { + writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }); +}); From 8cf349e8c2d1b92324bebbb52cc70f61e30d32aa Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:10 +0100 Subject: [PATCH 20/22] fix(cli): preserve env-specific dotenv precedence and fail on malformed extra dotenv files in stop/status (review: PRRT_kwDOErm0O86OHamj, PRRT_kwDOErm0O86OHaml) `legacyResolveProjectEnvironmentValues` unconditionally overlaid the entire `projectEnv.values` map over the locally-derived `merged` precedence, letting a plain `supabase/.env` value (which `loadProjectEnvironment` has no way to mark as lower priority) clobber a correctly-resolved `.env..local` value. Only ambient-sourced entries (`projectEnv.sources[key] === "ambient"`) now override `merged` - matching Go's `godotenv.Load`, where ambient always wins but files are first-processed-wins (pkg/config/config.go:1169-1207). `readDotEnvFile` also silently skipped unmatched lines instead of failing, diverging from Go's `loadEnvIfExists` (config.go:1209-1234), which propagates `godotenv`'s parse error up through `loadNestedEnv` and fails `Config.Load` before `stop`/`status` touch Docker - and from this repo's own `parseDotEnv`, which already fails the same way for `supabase/.env`(.local). --- .../shared/legacy-project-environment.ts | 40 ++++++++++++--- .../legacy-project-environment.unit.test.ts | 49 ++++++++++++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index f5c103a24f..7da708aba6 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -44,19 +44,29 @@ function candidateDotenvFilenames(env: string): ReadonlyArray { * `packages/config/src/project.ts`'s `parseDotEnv` closely enough for the env * vars this is used for (`SUPABASE_PROJECT_ID`, `SUPABASE_AUTH_*`), which * never need the full dotenv spec (multiline values, `export` re-declares). + * + * @throws on a line that isn't blank, a comment, or a `KEY=VALUE` assignment — + * matching Go's `loadEnvIfExists` (`pkg/config/config.go:1209-1234`), which + * propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and fails + * `Config.Load` before `stop`/`status` touch Docker, rather than silently + * skipping the bad line. Mirrors `packages/config/src/project.ts`'s + * `parseDotEnv`, which already fails the same way for `supabase/.env`(.local). */ function readDotEnvFile(path: string): Record | undefined { if (!existsSync(path)) return undefined; const contents = readFileSync(path, "utf8"); const values: Record = {}; + const lines = contents.split(/\r\n?|\n/); - for (const rawLine of contents.split(/\r\n?|\n/)) { + for (const [index, rawLine] of lines.entries()) { const line = rawLine.trim(); if (line === "" || line.startsWith("#")) continue; const match = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/.exec(line); - if (match === null) continue; + if (match === null) { + throw new Error(`failed to parse environment file: ${path} (line ${index + 1})`); + } const key = match[1]; if (key === undefined) continue; @@ -80,9 +90,20 @@ function readDotEnvFile(path: string): Record | undefined { /** * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` - * overrides (project id, auth fields) from — `projectEnv.values` (ambient + - * `supabase/.env`(.local), already correct) layered over the project-root and - * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover. + * overrides (project id, auth fields) from — the project-root and + * `SUPABASE_ENV`-selected files `loadProjectEnvironment` doesn't cover, layered + * under only the truly ambient-sourced entries of `projectEnv.values`. + * + * Only `projectEnv`'s AMBIENT entries outrank `merged`: `projectEnv.values` + * also carries plain `supabase/.env`/`.env.local` values it read itself, and + * those are not necessarily higher Go precedence than an env-specific file + * (`.env..local`/`.env.`) `merged` resolved — `loadProjectEnvironment` + * has no notion of `SUPABASE_ENV`-selected filenames, so it can't tell the two + * apart itself. `merged`'s own walk below already re-derives the full file + * precedence, including `supabase/.env`(.local), so only ambient needs to be + * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly + * those entries — see `loadProjectEnvironment`'s `ProjectEnvironment` shape). + * * Returns `undefined` when `projectEnv` is `null` (no `supabase/` project * found), matching callers' existing "fall back to `process.env` directly" * behavior. @@ -110,5 +131,12 @@ export function legacyResolveProjectEnvironmentValues( } } - return { ...merged, ...projectEnv.values }; + const ambientOverrides: Record = {}; + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + + return { ...merged, ...ambientOverrides }; } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index 0dc5c62571..f0c056693f 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -21,7 +21,10 @@ afterEach(() => { delete process.env["SUPABASE_ENV"]; }); -function fakeProjectEnv(values: Record = {}): ProjectEnvironment { +function fakeProjectEnv( + values: Record = {}, + sources: Record = {}, +): ProjectEnvironment { return { paths: { projectRoot: root, @@ -32,7 +35,10 @@ function fakeProjectEnv(values: Record = {}): ProjectEnvironment }, values, loadedPaths: [], - sources: {}, + // Default every given value to "ambient" unless the caller says otherwise — + // matches how most tests use this helper (representing an already-resolved, + // highest-precedence value) without forcing every call site to spell it out. + sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), }; } @@ -112,4 +118,43 @@ describe("legacyResolveProjectEnvironmentValues", () => { const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv()); expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("commented-project"); }); + + it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { + // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so + // a key it resolved from a plain supabase/.env file is NOT necessarily + // higher Go precedence than a same-named key from `.env..local` — + // only an "ambient" source outranks the file precedence computed locally. + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }); + + it("still lets a truly ambient-sourced value win over any file", () => { + process.env["SUPABASE_ENV"] = "development"; + writeFileSync( + join(supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const projectEnv = fakeProjectEnv( + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ); + const merged = legacyResolveProjectEnvironmentValues(projectEnv); + expect(merged?.["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }); + + it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { + writeFileSync(join(root, ".env"), "not a valid line\n"); + expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv())).toThrow( + /failed to parse environment file/, + ); + }); }); From 3b114c27316b825b8cfd7de9e94989503a5f5c55 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:20 +0100 Subject: [PATCH 21/22] fix(cli): resolve project env values before decoding config.toml in stop/status (review: PRRT_kwDOErm0O86OHami) Go's `Config.Load` runs `loadNestedEnv` (which loads project-root and `SUPABASE_ENV`-selected dotenv files into the process env) before `LoadEnvHook` decodes `env(...)` references in config.toml (pkg/config/config.go:735-738). Both handlers computed the Go-parity env map (`legacyResolveProjectEnvironmentValues`) only AFTER calling `loadProjectConfig`, so an `env(...)` reference backed only by one of those extra dotenv files (e.g. `auth.jwt_secret = "env(AUTH_JWT_SECRET)"` set in `.env.development.local`) decoded to the literal unsubstituted string instead of the real value. Move the resolution earlier and feed the extended values into `loadProjectConfig`'s `projectEnv` option (spreading over the original object so only `.values` changes), so config decode sees the same env map Go's decoder would. Wrapped in `Effect.try` since `legacyResolveProjectEnvironmentValues` can now throw on a malformed dotenv file (PRRT_kwDOErm0O86OHaml), mapped to each command's existing config-load error. --- .../legacy/commands/status/status.handler.ts | 33 ++++++++++++++----- .../src/legacy/commands/stop/stop.handler.ts | 21 ++++++++++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index eec5b04ecb..1299d4c5b8 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -111,8 +111,31 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); + + // `legacyResolveProjectEnvironmentValues` fills the gap between + // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's + // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected + // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for + // the full precedence chain. Resolved BEFORE `loadProjectConfig` decodes + // config.toml (not after) because Go's `Config.Load` runs `loadNestedEnv` + // before `LoadEnvHook` decodes `env(...)` references (`config.go:735-738`); + // an `env(...)` value sourced only from a project-root/`SUPABASE_ENV`- + // selected file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID`/`SUPABASE_AUTH_*` overrides read further below. + // A malformed extra dotenv file throws here (see `readDotEnvFile`), + // matching Go's `loadNestedEnv` propagating `godotenv`'s parse error + // instead of silently skipping the bad line. + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv), + catch: (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: projectEnv ?? undefined, + projectEnv: + projectEnv !== null + ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } + : undefined, }).pipe( Effect.mapError( (cause) => @@ -121,14 +144,6 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); - // `legacyResolveProjectEnvironmentValues` fills the gap between - // `loadProjectEnvironment` (supabase/.env(.local) + ambient only) and Go's - // `loadNestedEnv`, which also loads project-root and `SUPABASE_ENV`-selected - // dotenv files (`pkg/config/config.go:1169-1207`) — see its doc comment for - // the full precedence chain. Used below both for `SUPABASE_PROJECT_ID` and - // for the `SUPABASE_AUTH_*` overrides `legacyResolveStatusState` reads. - const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); - // 2. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton Go's // `Config.Validate` produces once at config-load time diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 0b7def8104..6ccdabac4b 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -86,19 +86,36 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject ), ); + // Resolved BEFORE `loadProjectConfig` decodes config.toml (not after): + // Go's `Config.Load` runs `loadNestedEnv` before `LoadEnvHook` decodes + // `env(...)` references (`config.go:735-738`), so an `env(...)`-valued + // `project_id` sourced only from a project-root/`SUPABASE_ENV`-selected + // file must already be visible to the decoder, not just to the + // `SUPABASE_PROJECT_ID` override read below. A malformed extra dotenv + // file throws here (see `readDotEnvFile`), matching Go's `loadNestedEnv` + // propagating `godotenv`'s parse error instead of silently skipping the + // bad line. + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv), + catch: (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still // resolves a project id via the workdir basename default. Only a // malformed file (`loadProjectConfig` failing rather than returning // `null`) is a hard error, matching `gen types`'s `loadConfig()` pattern. const loaded = yield* loadProjectConfig(cliConfig.workdir, { - projectEnv: projectEnv ?? undefined, + projectEnv: + projectEnv !== null + ? { ...projectEnv, values: projectEnvValues ?? projectEnv.values } + : undefined, }).pipe( Effect.mapError( (cause) => new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), ), ); - const projectEnvValues = legacyResolveProjectEnvironmentValues(projectEnv); const resolved = legacyResolveLocalProjectId( projectEnvValues?.["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], loaded?.config.project_id, From f9768ba79f2632c667da5d56f15df64748693809 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 Jul 2026 21:26:32 +0100 Subject: [PATCH 22/22] fix(cli): skip signing-key file reads in status when auth is disabled (review: PRRT_kwDOErm0O86OHamk) Go's `Config.Validate` only opens/parses `Auth.SigningKeysPath` inside `if c.Auth.Enabled` (pkg/config/config.go:1036,1059-1065), so a disabled auth section never touches that file, however stale or missing it is. `legacyResolveLocalConfigValues` called `loadFirstSigningKey` unconditionally whenever `signing_keys_path` was set, so `status` failed on valid local configs where auth is disabled but the path points nowhere. Gate the file read on `config.auth.enabled`; JWT-secret validation and anon/service_role key generation (`generateAPIKeys`, apikeys.go:43-73) stay unconditional, matching Go running that step outside the `Auth.Enabled` block. --- .../shared/legacy-local-config-values.ts | 13 ++++++++- .../legacy-local-config-values.unit.test.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 1ec3668695..dd44153565 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -175,6 +175,11 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ * `JSON.parse`'s raw Node error text through unwrapped. + * + * Callers must only invoke this when `config.auth.enabled` is true — Go's + * `Validate` nests the entire signing-keys read inside `if c.Auth.Enabled` + * (`pkg/config/config.go:1036,1059-1065`), so a disabled auth section never + * touches `signing_keys_path`, however stale or missing that file is. */ function loadFirstSigningKey(workdir: string, signingKeysPath: string): LegacyJwk | undefined { const absolutePath = isAbsolute(signingKeysPath) @@ -221,8 +226,14 @@ export function legacyResolveLocalConfigValues( config.auth.signing_keys_path, projectEnvValues, ); + // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): + // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a + // disabled auth section never opens/parses `signing_keys_path`, even a stale + // or missing one. JWT-secret validation and anon/service_role key generation + // (`generateAPIKeys`, `apikeys.go:43-73`) run unconditionally either way, so + // only this file read is gated. const signingKey = - signingKeysPath !== undefined && signingKeysPath.length > 0 + config.auth.enabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadFirstSigningKey(workdir, signingKeysPath) : undefined; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index e7c2b46315..6fdf15c576 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -316,5 +316,32 @@ describe("legacyResolveLocalConfigValues", () => { "unsupported algorithm: RS512", ); }); + + // Go's `Validate` only opens/parses `signing_keys_path` inside + // `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`) — a disabled + // auth section never touches the file, however stale or missing it is. + it("skips reading a missing signing_keys_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("skips reading a malformed signing_keys_path when auth is disabled", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Falls back to HMAC signing, matching an absent signing key. + const [, payload] = values.anonKey.split("."); + expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }); }); });