diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 9989e1e33f..3d136879cd 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -237,6 +237,10 @@ Concrete examples worth watching for as more commands land: This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow. +### `Config.Validate` parity has one home + +Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. + --- ## Legacy Port: Go CLI Output Parity @@ -403,6 +407,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 @@ -417,6 +422,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/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 0e596515b4..7536f3a071 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/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ebd07baabc..60e1869c1e 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -116,7 +116,10 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // Pass `ref` so a matching `[remotes.*]` block is merged over the base config // before decode (Go's `loadFromFile` with `Config.ProjectId` set). A duplicate // `project_id` across remotes surfaces Go's verbatim message. - const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref }).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { + projectRef: ref, + goViperCompat: true, + }).pipe( Effect.catchTag( "ProjectConfigParseError", (cause) => diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 1ddec7f3b3..853f933134 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -347,12 +347,14 @@ describe("legacy db reset", () => { }); it.live("finishes a local reset when bucket seeding hits a strict-loader-rejected config", () => { - // The bucket-seeding core re-loads config via the strict `@supabase/config` loader, - // which rejects some Go-valid configs (e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`). - // The seam's Go recreate already validated + rebuilt the DB, so aborting here would - // leave the reset half-done — warn and skip buckets so reset finishes like Go. + // The bucket-seeding core re-loads config via the strict `@supabase/config` loader. + // `SEED_ENABLED=maybe` is invalid for both Go's `strconv.ParseBool` and the TS + // loader's coercion, so the reload fails during decode (unlike e.g. `1`/`true`, + // which both now accept). The seam's Go recreate already validated + rebuilt the + // DB, so aborting here would leave the reset half-done — warn and skip buckets so + // reset finishes like Go. const previous = process.env["SEED_ENABLED"]; - process.env["SEED_ENABLED"] = "1"; + process.env["SEED_ENABLED"] = "maybe"; const { layer, out, seam } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', args: ["db", "reset"], diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index ece00daaa3..f1f8e6c4c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -41,6 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), + goViperCompat: true, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index b448c0307a..5080e4416b 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -89,7 +89,9 @@ const listExistingFunctionSlugs = Effect.fnUntraced(function* (workdir: string) }); const resolveTemplateInputs = Effect.fnUntraced(function* (workdir: string, slug: string) { - const loaded = yield* loadProjectConfig(workdir).pipe(Effect.orElseSucceed(() => null)); + const loaded = yield* loadProjectConfig(workdir, { goViperCompat: true }).pipe( + Effect.orElseSucceed(() => null), + ); const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; return { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 0718862e64..86b51f387b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -33,5 +33,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function debug, networkId, projectIdOverride: cliConfig.projectId, + goViperCompat: true, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 370af5aacf..8e3354fe2d 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1695,7 +1695,7 @@ describe("legacy functions serve integration", () => { "verify_jwt = true", "", "[remotes.override]", - 'project_id = "override-project"', + 'project_id = "overrideprojectaaaaa"', "", "[remotes.override.functions.hello]", "verify_jwt = false", @@ -1710,7 +1710,7 @@ describe("legacy functions serve integration", () => { const { layer } = setupServe({ childSpawner, - projectId: Option.some("override-project"), + projectId: Option.some("overrideprojectaaaaa"), }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), @@ -1724,20 +1724,20 @@ describe("legacy functions serve integration", () => { expect(deployMockState.volumeCalls).toEqual([ { - volumeName: "supabase_edge_runtime_override-project", - projectId: "override-project", + volumeName: "supabase_edge_runtime_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.networkCalls).toEqual([ { - networkMode: "supabase_network_override-project", - projectId: "override-project", + networkMode: "supabase_network_overrideprojectaaaaa", + projectId: "overrideprojectaaaaa", }, ]); expect(deployMockState.runCalls).toContainEqual( expect.objectContaining({ command: "docker", - args: ["container", "inspect", "supabase_db_override-project"], + args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], }), ); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 775a39140e..defbaad723 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -162,7 +162,7 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd).pipe( + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacyGenSigningKeyConfigParseError({ diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 912f1bb8a0..87113c6a42 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -248,9 +248,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } - const loadConfig = () => loadProjectConfig(cliConfig.workdir); + const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => - loadProjectConfig(cliConfig.workdir, { projectRef }); + loadProjectConfig(cliConfig.workdir, { projectRef, goViperCompat: true }); const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 422f503070..e03004eaa2 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -53,7 +53,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // literals stay as plain strings, so `Redacted.isRedacted(...)` is the // equivalent guard. const merged = new Map(); - const loaded = yield* loadProjectConfig(runtimeInfo.cwd).pipe( + const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { goViperCompat: true }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail( new LegacySecretsConfigParseError({ @@ -72,6 +72,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( loaded.config.edge_runtime, projectEnv, "edge_runtime", + { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { if (Redacted.isRedacted(value)) { diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 24b2543055..f5d04275b7 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -45,6 +45,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( ? yield* projectRefResolver.loadProjectRef(Option.none()) : ""; linkedRef = projectRef; + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group diff --git a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md index 089b2340c3..bc43278256 100644 --- a/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/status/SIDE_EFFECTS.md @@ -2,15 +2,17 @@ ## 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 | +| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------- | ------ | ------ | +| `~/.supabase/telemetry.json` | JSON | always | ## API Routes @@ -18,67 +20,186 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +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`) | +| `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`. ## 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 | +| `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` | +| `1` | `api.enabled` and `api.tls.enabled` are true and only one of `api.tls.cert_path`/`key_path` is set (Go's `Config.Validate` rejects this at config-load time) | +| `1` | `api.enabled` and `api.tls.enabled` are true, both `cert_path` and `key_path` are set, but one of the files can't be read | + +## 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. -- `--exclude` (hidden, repeatable) omits named containers from the output; matches Go's `cmd/status.go:39-40`. -- `--ignore-health-check` (hidden, boolean) exits `0` even when a service is unhealthy; matches Go's `cmd/status.go:41-42`. +- `-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 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 + 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. 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`. +- 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 201bce98f0..12be889ebb 100644 --- a/apps/cli/src/legacy/commands/status/status.command.ts +++ b/apps/cli/src/legacy/commands/status/status.command.ts @@ -1,19 +1,47 @@ +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 { 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, @@ -22,6 +50,18 @@ const config = { 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"), @@ -35,5 +75,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.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([]); + }); +}); 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..9e72e14fbb --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -0,0 +1,58 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `status`'s own `PreRunE` + * (override-name parsing) or `RunE`, so a bad explicit workdir must fail here + * first, before config load or any Docker access. + */ +export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ + readonly message: string; +}> {} + +/** `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; +}> {} + +/** + * `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 566427260c..7e984a0f93 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -1,12 +1,350 @@ -import { Effect } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Schema } 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, + legacySanitizeProjectId, + 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 { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; +import { + LegacyStatusConfigLoadError, + LegacyStatusDbInspectError, + LegacyStatusDbNotReadyError, + LegacyStatusDbNotRunningError, + LegacyStatusInvalidConfigError, + LegacyStatusListError, + LegacyStatusOverrideParseError, + LegacyStatusWorkdirError, +} from "./status.errors.ts"; +import { legacyRenderStatusPretty } from "./status.pretty.ts"; +import { + LEGACY_STATUS_FIELDS, + legacyGateStatusState, + legacyResolveStatusLocalState, + legacyStatusContainerIds, + legacyStatusValuesFromState, +} 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. `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, +): 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)) { + continue; + } + 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); - for (const name of flags.exclude) args.push("--exclude", name); - if (flags.ignoreHealthCheck) args.push("--ignore-health-check"); - 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; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // 0. Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before `status`'s own + // `PreRunE` (override-name parsing) or `RunE`. A missing or non-directory + // path fails immediately, so this must win over every later error. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStatusWorkdirError({ message: error.message })), + ); + + // 1. `--override-name KEY=VALUE` parsing — mirroring Go's Cobra wiring, + // where override validation runs in `PreRunE` (`cmd/status.go:21-27`) and + // Cobra's execute loop returns as soon as `PreRunE` errors, never calling + // `RunE` (`spf13/cobra@v1.10.2/command.go:999-1015`). So a malformed + // `--override-name` entry fails before `status.Run` ever loads config or + // touches Docker (`internal/status/status.go:101-116`) — it must win over + // a config-load error or a Docker/DB health-check error, not be masked by + // either. `overrides` itself is only consumed much later, by + // `legacyStatusValuesFromState` below. + const overrides = yield* parseOverrides(flags.overrideName); + + // 2. `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. + // `search: false` on both loaders below: `cliConfig.workdir` already IS + // Go's fully-resolved chdir target (`legacy-cli-config.layer.ts`'s + // `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default- + // searched resolution, `apps/cli-go/internal/utils/misc.go:231-247`), so + // letting `@supabase/config`'s `findProjectPaths` climb ancestors again on + // top of that would let an unrelated ancestor project's config.toml win + // when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + 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. `workdir` is passed through so + // dotenv files under `/supabase`/`workdir` are still discovered + // even when `projectEnv` is `null` (no config.toml there) — Go's own + // `loadNestedEnv` runs unconditionally, before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + catch: (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + }); + + const loaded = yield* loadProjectConfig(cliConfig.workdir, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, reporting ports/keys for a config Go never reads. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStatusConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // 3. Resolve + VALIDATE config-derived state before any Docker call — + // matching Go's `flags.LoadConfig` (config load + `Validate`, + // `internal/utils/flags/config_path.go:12` -> `pkg/config/config.go:882`), + // which runs entirely before `assertContainerHealthy`/container listing + // (`internal/status/status.go:101-116`). `legacyResolveStatusLocalState` + // can throw `LegacyInvalidJwtSecretError` (a short `auth.jwt_secret`), + // `LegacyInvalidPortEnvOverrideError`/`LegacyInvalidBoolEnvOverrideError` + // (a malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override), or a + // signing-keys-file read/parse error — all of these must fail here, not + // be masked by a Docker/DB error when the local stack happens to be + // unavailable. `hostname` has no Docker dependency either, so it's + // resolved here rather than later. + const hostname = legacyGetHostname(); + const localState = yield* Effect.try({ + try: () => + legacyResolveStatusLocalState( + config, + hostname, + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + // 4. 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( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ), + ); + const dbContainerId = localDbContainerId(projectId); + + // 5. 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). + // `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.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}`, + }), + ); + } + } + + // 6. 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"); + } + + // 7. Merge health-derived exclusions with the user's --exclude flag. + const excluded = [...stopped, ...flags.exclude]; + + // 8. Apply the exclude-based gating on top of the already-validated + // `localState` (Go's `toValues()` exclude filtering, `status.go:55-61`). + // Pure/non-throwing — see `legacyGateStatusState`'s doc comment. Reused + // for both the real and pretty-mode (empty-override) value maps below, + // matching this handler's pre-split behavior. + const containerIds = legacyStatusContainerIds(projectId); + const state = legacyGateStatusState(localState, containerIds, excluded); + const { values } = legacyStatusValuesFromState(state, overrides); + + // 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. + // 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 renderPretty = Effect.fnUntraced(function* () { + yield* output.raw( + `${legacyAqua("supabase")} local development setup is running.\n\n`, + "stderr", + ); + const pretty = legacyStatusValuesFromState(state, new Map()); + yield* output.raw(legacyRenderStatusPretty(pretty.values, pretty.names)); + }); + + // 9. Output branching: Go's -o (env|json|toml|yaml|pretty) is a complete + // format choice and takes priority over --output-format (root.ts:119-121, + // matching functions/list's list.handler.ts:115-118) — only an ABSENT -o + // defers to --output-format for 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; + } + if (goFmt === "pretty") { + yield* renderPretty(); + return; + } + + // goFmt is undefined — 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* renderPretty(); + }).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 index 1c87049978..d1988d5569 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,58 +1,958 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; -import { legacyStatus } from "./status.handler.ts"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, vi } from "vitest"; + +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-"); + +afterEach(() => { + delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +}); + +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", + }), + ); + } -function setupLegacyStatus() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push(args); + 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, + }); }), - execCapture: () => Effect.succeed(""), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); +const HEALTHY_DB_STATE = JSON.stringify({ + Status: "running", + Running: true, + 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; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.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, }); - return { layer, calls }; + + 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 }; } -const baseFlags: LegacyStatusFlags = { - overrideName: [], - exclude: [], - ignoreHealthCheck: false, -}; +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( + "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) => { + // 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( + "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", + () => { + // Pairs with "fails when the db container is unhealthy" below (ignoreHealthCheck: false, + // the default) to cover both sides of Go's `if !ignoreHealthCheck { assertContainerHealthy }`. + const { layer, child } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, + }), + }), + }); + 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)); + }); -describe("legacy status", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // `status` never binds a --project-ref flag, so it must still fail on a + // config-wide duplicate, before ever reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "previewrefaaaaaaaaaa" + +[remotes.b] +project_id = "previewrefaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus(baseFlags); - expect(calls).toEqual([["status"]]); + 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("forwards --override-name for each provided override", () => { - const { layer, calls } = setupLegacyStatus(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only a + // remote that ends up selected — so this must fail closed before status + // reaches Docker, even with no --project-ref requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { - yield* legacyStatus({ - ...baseFlags, - overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"], + 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( + "decodes a comma-separated string into an array field ([]string) for status to proceed", + () => { + // Go's StringToSliceHookFunc (mapstructure) splits a plain string literal + // into a []string for a []string field like additional_redirect_urls — + // this only runs when goViperCompat is on. Pin that status still proceeds + // past config load (and on to a successful Docker inspect/list) rather + // than treating the string literal as a decode error. + const { layer } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', }); - expect(calls).toEqual([["status", "--override-name", "api.url=NEXT_PUBLIC_SUPABASE_URL"]]); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // Go's `external.validate()` (config.go:1418-1423) disables a bare + // [auth.external.slack] block and warns — mirrored by + // `normalizeDeprecatedExternalProviders` in packages/config's io.ts, gated + // on `goViperCompat` (confirmed already wired in status.handler.ts). The + // WARN goes out via Effect's `Console.error`, not this file's `Output` + // service, so it must be observed with a raw console.error spy — same + // idiom as packages/config/src/io.unit.test.ts's deprecated-provider tests. + const { layer } = setup({ + configContents: 'project_id = "demo"\n[auth.external.slack]\nenabled = true\n', + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: disabling deprecated "slack" provider'), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before config + // load or any Docker call — a missing path must fail immediately, not + // fall through to the workdir-basename default and inspect Docker. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, 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("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, 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("LegacyStatusWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).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), entirely before assertContainerHealthy/ + // container listing (internal/status/status.go:101-116) — so no Docker + // call happens, same as the malformed config.toml case above. + 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).toEqual([]); + }).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` -> + // 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* () { + 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)); + }); + + 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("forwards the hidden --exclude and --ignore-health-check flags", () => { - const { layer, calls } = setupLegacyStatus(); + 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("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({ - overrideName: [], - exclude: ["db", "kong"], - ignoreHealthCheck: true, + 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( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), }); - expect(calls).toEqual([ - ["status", "--exclude", "db", "--exclude", "kong", "--ignore-health-check"], - ]); + 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(projectId)); + expect(inspectCall?.args).not.toContain(localDbContainerId("ancestor-project")); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); + const { layer, child } = setup({ + skipConfig: true, + route: defaultRoute({ runningNames: legacyServiceContainerIds("no-config-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("no-config-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 + // 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", Running: false }), + }), + }); + 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( + "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) gates on the boolean + // `resp.State.Running`, not the status string — a paused container can + // report `Running: true` alongside `Status: "paused"`, and Go continues + // past the not-running branch to the health check in that case. + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "paused", + Running: true, + Health: { Status: "healthy" }, + }), + }), + }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + }).pipe(Effect.provide(layer)); + }, + ); + + 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 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)) { + 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)); + }); + + it.live("fails when the db container is unhealthy", () => { + const { layer } = setup({ + route: defaultRoute({ + dbInspectStdout: JSON.stringify({ + Status: "running", + Running: true, + 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("omits every service named across multiple --exclude entries", () => { + const { layer, out } = setup({ goOutput: Option.some("json") }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + const storageId = legacyServiceContainerIds("demo")[5]!; + yield* legacyStatus(flags({ exclude: [authId, storageId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); + expect(parsed.STORAGE_S3_URL).toBeUndefined(); + expect(parsed.API_URL).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { + // Go's `excluded := append(stopped, exclude...)` merges the health-derived + // stopped list with the user-supplied --exclude list — both must take effect + // together, not just whichever one the command would have applied alone. + const { layer, out } = setup({ + goOutput: Option.some("json"), + // kong (index 0) is absent from the running set, so it's auto-detected as stopped. + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + const authId = legacyServiceContainerIds("demo")[1]!; + yield* legacyStatus(flags({ exclude: [authId] })); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude + expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating + }).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("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* () { + yield* legacyStatus( + flags({ overrideName: ["not.a.real.field=NAME", "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.NAME).toBeUndefined(); + }).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("lets --output pretty win over --output-format json", () => { + // Explicit `-o pretty` is a complete Go format choice (root.ts:119-121, + // matching functions/list) and must render the table, not defer to the + // TS-only --output-format json/stream-json branch. + const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); + return Effect.gen(function* () { + yield* legacyStatus(flags()); + expect(out.stderrText).toContain("local development setup is running."); + expect(out.stdoutText).toContain("🌐 APIs"); + 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.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/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..4759a75af6 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.ts @@ -0,0 +1,495 @@ +import type { ProjectConfig } from "@supabase/config"; + +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { legacyServiceContainerIds } from "../../shared/legacy-docker-ids.ts"; +import { + legacyEnvOverrideBool, + 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), + }; +} + +/** + * 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; + readonly local: LegacyLocalConfigValues; +} + +/** + * 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 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; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * The config-load/`Validate`-equivalent half of {@link LegacyStatusState} — + * everything that can THROW, and none of it depends on `excluded`/ + * `containerIds`. Split out so `status.handler.ts` can resolve and validate + * this before any Docker call, matching Go's `flags.LoadConfig` (config load + * + `Validate`, `internal/utils/flags/config_path.go:12` -> + * `pkg/config/config.go:882`) running entirely before `assertContainerHealthy`/ + * container listing (`internal/status/status.go:101-116`) — a bad + * `auth.jwt_secret` or malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` + * override must fail here, not be masked by a Docker/DB error when the local + * stack happens to be unavailable. + */ +export interface LegacyStatusLocalState { + readonly config: ProjectConfig; + readonly local: LegacyLocalConfigValues; + readonly apiEnabled: boolean; + readonly studioSectionEnabled: boolean; + readonly authSectionEnabled: boolean; + readonly inbucketSectionEnabled: boolean; + readonly storageSectionEnabled: boolean; + readonly edgeRuntimeEnabled: boolean; + readonly storageS3ProtocolEnabled: boolean; +} + +/** + * Port of the throwing, non-Docker-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 `.enabled` gates, + * with NO reference to `excluded`/`containerIds` — see {@link legacyGateStatusState} + * for the Docker-dependent, non-throwing half this composes with (in + * `status.handler.ts`, or via {@link legacyStatusValues} for callers that + * don't need to run validation before Docker calls). + * + * Each `.enabled` gate is read through {@link legacyEnvOverrideBool}, not the + * raw decoded `config.
.enabled`, because Go's `status.toValues()` + * (`status.go:55-61`) reads `utils.Config.*.Enabled` — a package-level struct + * Viper has already applied any `SUPABASE_
_ENABLED` env/dotenv + * override to (`SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` + + * `ExperimentalBindStruct()`, `pkg/config/config.go:580-586`) — generically, + * not just for `auth.enabled`. Skipping this would mean a stack Go started + * with e.g. `SUPABASE_API_ENABLED=true` over a `false` TOML value has Kong/ + * PostgREST running while native `status` omits them entirely. + * + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @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 legacyResolveStatusLocalState( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusLocalState { + const local = legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const studioSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const authSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const inbucketSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const storageSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ); + const edgeRuntimeEnabled = legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + "edge_runtime.enabled", + projectEnvValues, + ); + const storageS3ProtocolEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", + projectEnvValues, + ); + + return { + config, + local, + apiEnabled, + studioSectionEnabled, + authSectionEnabled, + inbucketSectionEnabled, + storageSectionEnabled, + edgeRuntimeEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * The Docker-dependent, non-throwing half of Go's `toValues()`: applies + * `excluded` (matching 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) on top of + * an already-resolved {@link LegacyStatusLocalState}. Pure: every throwing + * concern already ran in {@link legacyResolveStatusLocalState}. + */ +export function legacyGateStatusState( + localState: LegacyStatusLocalState, + containerIds: LegacyStatusContainerIds, + excluded: ReadonlyArray, +): LegacyStatusState { + const { config, local } = localState; + const { apiEnabled, studioSectionEnabled, authSectionEnabled } = localState; + const { inbucketSectionEnabled, storageSectionEnabled } = localState; + const { edgeRuntimeEnabled, storageS3ProtocolEnabled } = localState; + const isExcluded = (id: string) => excluded.includes(id); + + const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + const postgrestEnabled = + kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + const studioEnabled = + studioSectionEnabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); + const authEnabled = + authSectionEnabled && !isExcluded(containerIds.auth) && !isExcluded(GOTRUE_IMAGE_NAME); + const inbucketEnabled = + inbucketSectionEnabled && !isExcluded(containerIds.inbucket) && !isExcluded(MAILPIT_IMAGE_NAME); + const storageEnabled = + storageSectionEnabled && !isExcluded(containerIds.storage) && !isExcluded(STORAGE_IMAGE_NAME); + const functionsEnabled = + edgeRuntimeEnabled && + !isExcluded(containerIds.edgeRuntime) && + !isExcluded(EDGE_RUNTIME_IMAGE_NAME); + + return { + config, + local, + kongEnabled, + postgrestEnabled, + studioEnabled, + authEnabled, + inbucketEnabled, + storageEnabled, + functionsEnabled, + storageS3ProtocolEnabled, + }; +} + +/** + * Applies `--override-name` remapping to an already-resolved {@link LegacyStatusState}. + * Pure and non-throwing — every failure mode of `toValues()` lives in + * {@link legacyResolveStatusLocalState}, which runs once per `status` invocation. + */ +export function legacyStatusValuesFromState( + state: LegacyStatusState, + overrides: ReadonlyMap, +): LegacyStatusValuesResult { + const { local, kongEnabled, postgrestEnabled, studioEnabled, authEnabled } = state; + const { inbucketEnabled, storageEnabled, functionsEnabled, storageS3ProtocolEnabled } = state; + const names = resolveOutputNames(overrides); + + // 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 && storageS3ProtocolEnabled) { + values[names.storageS3Url] = local.storageS3Url; + values[names.storageS3AccessKeyId] = local.storageS3AccessKeyId; + values[names.storageS3SecretAccessKey] = local.storageS3SecretAccessKey; + values[names.storageS3Region] = local.storageS3Region; + } + + return { values, names, local }; +} + +/** + * Convenience wrapper combining {@link legacyResolveStatusLocalState} + + * {@link legacyGateStatusState} + {@link legacyStatusValuesFromState} in one + * call — used directly by tests that only need a single override map. + * `status.handler.ts` calls the three separately instead, so it can resolve + + * validate `localState` before any Docker call (see + * {@link legacyResolveStatusLocalState}'s doc comment), and reuse the gated + * `state` 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, + projectEnvValues: Readonly> | undefined = undefined, + /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ + document: Readonly> | undefined = undefined, +): LegacyStatusValuesResult { + const localState = legacyResolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + const state = legacyGateStatusState(localState, containerIds, excluded); + return legacyStatusValuesFromState(state, overrides); +} 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..3ae5f60004 --- /dev/null +++ b/apps/cli/src/legacy/commands/status/status.values.unit.test.ts @@ -0,0 +1,793 @@ +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + legacyShortContainerImageName, + 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(); +const WORKDIR = "/tmp/status-values-test"; + +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, + WORKDIR, + ); + 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, + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); + }); + }); + + 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, + 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, + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + 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, + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + 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, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); + }); + }); + + describe("SUPABASE_*_ENABLED env overrides", () => { + // Go's `status.toValues()` (`status.go:55-61`) reads `utils.Config.*.Enabled` + // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding + // (`pkg/config/config.go:580-586`) has already applied any + // `SUPABASE_
_ENABLED` override — generically, not just for auth. + // `legacyResolveStatusLocalState` must read the same post-override value + // for every gate, not the raw decoded `config.
.enabled`. + + it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { + const config = baseConfig({ api: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_API_ENABLED: "true", + }, + ); + expect(values.API_URL).toBeDefined(); + expect(values.REST_URL).toBeDefined(); + }); + + it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_API_ENABLED: "false" }, + ); + expect(values.API_URL).toBeUndefined(); + }); + + it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { + const config = baseConfig({ studio: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STUDIO_ENABLED: "true", + }, + ); + expect(values.STUDIO_URL).toBeDefined(); + }); + + it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { + // Reproduces the exact scenario a Go-started stack can hit: TOML says + // auth is disabled, but the running stack was actually started with + // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and + // status must still print its credentials. + const config = baseConfig({ auth: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_AUTH_ENABLED: "true", + }, + ); + expect(values.PUBLISHABLE_KEY).toBeDefined(); + expect(values.ANON_KEY).toBeDefined(); + expect(values.SERVICE_ROLE_KEY).toBeDefined(); + }); + + it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_AUTH_ENABLED: "false" }, + ); + expect(values.PUBLISHABLE_KEY).toBeUndefined(); + }); + + it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { + const config = baseConfig({ local_smtp: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_LOCAL_SMTP_ENABLED: "true", + }, + ); + expect(values.MAILPIT_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { + const config = baseConfig({ storage: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { + const config = baseConfig({ edge_runtime: { enabled: false } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_EDGE_RUNTIME_ENABLED: "true", + }, + ); + expect(values.FUNCTIONS_URL).toBeDefined(); + }); + + it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { + const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); + const { values } = legacyStatusValues( + config, + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "true", + }, + ); + expect(values.STORAGE_S3_URL).toBeDefined(); + }); + + it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { + const { values } = legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + NONE, + NO_OVERRIDES, + WORKDIR, + { SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false" }, + ); + expect(values.STORAGE_S3_URL).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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + 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, + WORKDIR, + ); + expect(values.STORAGE_S3_URL).toBeUndefined(); + expect(values.STUDIO_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + }); +}); + +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"); + 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..300c06c297 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,102 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +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. +- 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 + `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 gates `--all` on the Docker daemon's API version (`legacy-container-cli.ts`'s + `legacyDockerSupportsVolumePruneAllFlag`, checked via `docker version --format +'{{.Server.APIVersion}}'`), matching Go's `Docker.ClientVersion() >= "1.42"` check + (`docker.go:126-133`) exactly. This isn't cosmetic: Docker CLI's own `--all` flag on + `volume prune` is annotated `version: "1.42"` and enforced by Cobra's `Args` validator + _before_ pruning runs, so sending it unconditionally on a pre-1.42 daemon hard-fails the + whole call instead of just pruning a narrower set. On the Podman fallback, `--all` is + omitted unconditionally 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 + `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..64fb443c6b 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 = { @@ -17,15 +25,41 @@ const config = { noBackup: Flag.boolean("no-backup").pipe( Flag.withDescription("Deletes all data volumes after stopping."), ), + // Modelled as `Option` (presence = pflag `Changed`), not a plain + // boolean: Cobra's `MarkFlagsMutuallyExclusive("project-id", "all")` + // (`apps/cli-go/cmd/stop.go:31`) rejects the command whenever BOTH flags + // were explicitly set, regardless of the value `--all` was set to — the + // vendored cobra@v1.10.2 `flag_groups.go:139` check is + // `groupStatus[group][name] = flag.Changed`, not the flag's boolean value. + // A plain `Flag.boolean` here would make `--project-id x --all=false` + // indistinguishable from `--project-id x` (no `--all` at all), silently + // accepting a combination Go rejects. all: Flag.boolean("all").pipe( Flag.withDescription("Stop all local Supabase instances from all projects across the machine."), + Flag.optional, ), } as const; 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..ac8d49db97 --- /dev/null +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -0,0 +1,62 @@ +import { Data } from "effect"; + +/** + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a + * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: + * 231-250`), which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` + * (`apps/cli-go/cmd/root.go:93-105`) — before `stop`'s own flag validation or + * `RunE`, so a bad explicit workdir must fail here first, before config load + * or any Docker access. + */ +export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ + readonly message: string; +}> {} + +/** + * `--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..3e31196de6 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -1,15 +1,410 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, FileSystem, Option, Result, Schema } 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, + legacyDockerSupportsVolumePruneAllFlag, +} from "../../shared/legacy-container-cli.ts"; +import { + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../shared/legacy-docker-ids.ts"; +import { + legacyListContainersByLabel, + legacyListVolumesByLabel, +} from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyGetHostname } from "../../shared/legacy-hostname.ts"; +import { legacyResolveLocalConfigValues } from "../../shared/legacy-local-config-values.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { legacyValidateWorkdirIsDirectory } from "../../shared/legacy-workdir-validation.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; +import { + LegacyStopConfigLoadError, + LegacyStopContainerError, + LegacyStopContainerPruneError, + LegacyStopListError, + LegacyStopMutuallyExclusiveError, + LegacyStopNetworkPruneError, + LegacyStopVolumePruneError, + LegacyStopWorkdirError, +} 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. + * + * "env" is Go's post-`loadNestedEnv` value, not just the ambient shell + * 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. + * `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. It still returns a usable map (falling back to + * `/supabase`/`workdir` and `process.env` itself) even when no + * `supabase/` config file exists at `workdir`, matching Go's `loadNestedEnv` + * running unconditionally before `config.toml` is ever opened + * (`pkg/config/config.go:786-793`) — the `?? process.env[...]` fallback below + * only still matters for keys neither source produced. + * + * 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`). + * + * 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"]) { + // `internal/stop/stop.go:17`'s `if !all` reads the resolved value (not + // presence), so this branch stays value-based — `Option.getOrElse` mirrors + // Cobra's `BoolVar` default of `false` when `--all` was never passed. + if (Option.getOrElse(flags.all, () => false)) return ""; + if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { + return flags.projectId.value; + } + + // `search: false`: `cliConfig.workdir` already IS Go's fully-resolved chdir + // target (`legacy-cli-config.layer.ts`'s `resolveWorkdir` mirrors + // `ChangeWorkDir`'s explicit-exact-vs-default-searched resolution, + // `apps/cli-go/internal/utils/misc.go:231-247`), so letting + // `@supabase/config`'s `findProjectPaths` climb ancestors again on top of + // that would let an unrelated ancestor project's config.toml win when + // `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with no + // `supabase/config.toml` of its own — Go never searches past the exact + // (explicit or defaulted) workdir (`NewPathBuilder`, `pkg/config/utils.go: + // 43-48`). + const projectEnv = yield* loadProjectEnvironment({ + cwd: cliConfig.workdir, + baseEnv: process.env, + search: false, + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) + // omits `.env.local` from its candidate list whenever + // `SUPABASE_ENV=test` — a malformed or intentionally non-test + // `supabase/.env.local` is then invisible to Go and must not fail + // config loading here either. `legacyResolveProjectEnvironmentValues` + // below already applies this same gate for the project-root pass (see + // its `candidateDotenvFilenames`); this mirrors it for the + // `supabase/`-dir pass `loadProjectEnvironment` itself performs. + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + + // 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. `workdir` is passed through so dotenv files under + // `/supabase`/`workdir` are still discovered even when + // `projectEnv` is `null` (no config.toml there) — Go's own `loadNestedEnv` + // runs unconditionally, before `config.toml` is ever opened + // (`pkg/config/config.go:786-793`). + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cliConfig.workdir), + 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 !== null ? { ...projectEnv, values: projectEnvValues } : undefined, + search: false, + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) only + // ever resolves `supabase/config.toml` — it has no concept of a JSON + // project config file. Without this, a workdir with a stray + // `config.json` would make `loadProjectConfig` prefer it over + // `config.toml`, potentially stopping containers for the wrong project. + tomlOnly: true, + goViperCompat: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStopConfigLoadError({ message: `failed to read config: ${String(cause)}` }), + ), + ); + const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); + + // VALIDATE config before any Docker call, matching Go's `flags.LoadConfig` + // (config load + `Validate`, `internal/utils/flags/config_path.go:10-14` -> + // `pkg/config/config.go:882`), which the default `stop` path runs in full + // (`internal/stop/stop.go:15-25`) before ever touching Docker — unlike the + // `--all`/`--project-id` branches above, which bypass config loading + // entirely and so must NOT run this. `legacyResolveLocalConfigValues` is + // reused purely for its throwing side effects (its resolved URLs/keys are + // discarded); it gives `stop` the same partial-but-growing `Config.Validate` + // parity `status` already has (`status.handler.ts`), rather than a one-off + // re-implementation. `legacyGetHostname` has no Docker dependency, so it's + // safe to call speculatively here too. + yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + legacyGetHostname(), + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => + new LegacyStopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const resolved = legacyResolveLocalProjectId( + projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + config.project_id, + cliConfig.workdir, + ); + return legacySanitizeProjectId(resolved); + }, +); 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; + const fs = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // unconditionally `os.Chdir`s the resolved `--workdir`/`SUPABASE_WORKDIR` + // in `PersistentPreRunE` (`cmd/root.go:93-105`) — before any of `stop`'s + // own flag validation or `RunE`. A missing or non-directory path fails + // immediately, so this must win over every later error, including the + // `--project-id`/`--all` mutual-exclusivity check below. + yield* legacyValidateWorkdirIsDirectory(cliConfig.workdir, fs).pipe( + Effect.mapError((error) => new LegacyStopWorkdirError({ message: error.message })), + ); + + // Presence-based, matching Cobra's `Changed` check (see the doc comment on + // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` + // must reject too, not just `--all`/`--all=true`. + if (Option.isSome(flags.projectId) && Option.isSome(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); + + // 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, { + 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`). + // + // `stdout`/`stderr: "ignore"` on every exit-code-only call below: none of + // these read the child's own output, and the default `"pipe"` stdio + // otherwise leaves an OS pipe unread — once `docker`/`podman` write + // enough to it (e.g. `container prune`'s "Deleted Containers" ID list on + // a host with many stale containers, most likely under `stop --all`), + // the child blocks on write() and `stop` hangs. Matches the existing + // `stdio: "ignore"` precedent for the same "exit-code-only" shape in + // `legacy-pgdelta.seam.layer.ts`. + const stopResults = yield* Effect.all( + containerIds.map((id) => + containerCliExitCode(spawner, ["stop", id], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).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}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).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 API >= 1.42 + // (`docker.go:126-133`, `Docker.ClientVersion() >= "1.42"`): Docker + // CLI's own `volume prune --all` flag is annotated `version: "1.42"` + // (`docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and enforced + // by Cobra's `Args` validator *before* `RunE` runs + // (`cmd/docker/docker.go:659-660`) — on an older daemon, passing + // `--all` unconditionally would hard-fail this whole call and prune + // nothing, not just prune a narrower set. There's no persistent + // Engine API client here to ask the negotiated version directly (Go + // talks to the Docker Engine API, never a `docker` binary), so + // {@link legacyDockerSupportsVolumePruneAllFlag} asks the `docker` CLI + // itself via `docker version` and mirrors Go's gate exactly. + // + // Podman is a Docker-CLI-compatible fallback this port adds, not something + // Go itself has, so there's no Go behavior to match on that path — 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 dockerSupportsAll = yield* legacyDockerSupportsVolumePruneAllFlag(spawner); + const volumePruneExitCode = yield* containerCliExitCode( + spawner, + [ + "volume", + "prune", + "--force", + ...(dockerSupportsAll ? ["--all"] : []), + "--filter", + `label=${filterValue}`, + ], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ["volume", "prune", "--force", "--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}`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).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" }), + ); + } + }); + + 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..3ac32c623c 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,1122 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { basename, 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 { vi } from "vitest"; + +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: Option.none(), + ...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`); +} + +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; +} + +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, `docker version` reports + * an API version comfortably at/above the `volume prune --all` gate (1.42, see + * `legacyDockerSupportsVolumePruneAllFlag`), everything else succeeds empty. + */ +function defaultRoute( + opts: { + readonly containerIds?: ReadonlyArray; + readonly volumeNames?: ReadonlyArray; + readonly dockerApiVersion?: string; + } = {}, +) { + const containerIds = opts.containerIds ?? ["c1"]; + const volumeNames = opts.volumeNames ?? []; + const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "ps") return { stdout: containerIds }; + if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; + if (args[0] === "version") return { stdout: [dockerApiVersion] }; + 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; + /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ + readonly workdir?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.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("Stopping containers..."); + 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)); + }, + ); + + 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* () { + yield* legacyStop(flags({ all: Option.some(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("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(flags({ all: Option.some(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("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(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("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("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( + "does not climb to an ancestor project's config.toml when workdir has none of its own", + () => { + // Go's ChangeWorkDir uses an explicit/defaulted workdir exactly, with no + // ancestor search (apps/cli-go/internal/utils/misc.go:231-247) — mirrored + // here by `search: false`. A workdir with no supabase/config.toml of its + // own must fall back to defaults (workdir-basename project id), not an + // ancestor project's config.toml, even though `cliConfig.workdir` sits + // right inside one. + const nestedWorkdir = join(tempRoot.current, "nested"); + mkdirSync(nestedWorkdir, { recursive: true }); + writeConfig(tempRoot.current, "ancestor-project"); + const projectId = basename(nestedWorkdir); + const { layer, child } = setup({ + workdir: nestedWorkdir, + skipConfig: true, + 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=${projectId}`, + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { + // Go's loadNestedEnv runs unconditionally, before config.toml is ever + // opened (pkg/config/config.go:786-793) — a supabase/.env-only project id + // must still be honored even when there's no config.toml to fall back to + // template defaults from. + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-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=no-config-project", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }); + + 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("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) + // `os.Chdir`s the explicit workdir in `PersistentPreRunE`, before any of + // `stop`'s own flag validation, config load, or Docker access — a missing + // path must fail immediately, not fall through to the workdir-basename + // default and prune under that name. + const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); + 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("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, + ); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { + const filePath = join(tempRoot.current, "not-a-directory"); + writeFileSync(filePath, ""); + const { layer, child } = setup({ workdir: filePath, skipConfig: true }); + 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("LegacyStopWorkdirError"); + expect(JSON.stringify(exit.cause)).toContain( + `failed to change workdir: chdir ${filePath}: not a directory`, + ); + } + expect(child.spawned).toEqual([]); + }).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: Option.some(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)); + }); + + // Cobra's `MarkFlagsMutuallyExclusive` mutex is presence-based (`Changed`), + // not value-based — `--all=false` still counts as "set" alongside + // `--project-id`, so this must reject too, not just `--all`/`--all=true`. + it.live("rejects --project-id together with an explicit --all=false", () => { + 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: Option.some(false) })), + ); + 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( + "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", + () => { + // Docker CLI's own `volume prune --all` flag requires API >= 1.42 and + // hard-fails (pruning nothing) on an older daemon — Go avoids ever + // sending it by checking `Docker.ClientVersion() >= "1.42"` + // (docker.go:126-133). This mirrors that gate via `docker version`. + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.41" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("includes --all in docker's volume prune when the API is exactly 1.42", () => { + const { layer, child } = setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.42" }), + }); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && 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)); + }); -describe("legacy stop", () => { - it.live("forwards no extra flags when defaults are used", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + // Go's Config.Validate builds the duplicate map across all [remotes.*] + // blocks unconditionally (config.go:503-518), so this must fail before + // stop ever selects a remote or touches Docker — not just when a + // matching --project-ref is requested. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "aaaaaaaaaaaaaaaaaaaa" + +[remotes.b] +project_id = "aaaaaaaaaaaaaaaaaaaa" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop(baseFlags); - expect(calls).toEqual([["stop"]]); + 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("forwards --backup=false when the hidden --backup flag is disabled", () => { - const { layer, calls } = setupLegacyStop(); + it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load, so an invalid + // format must fail closed before stop reaches Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "short" +`, + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { - yield* legacyStop({ ...baseFlags, backup: false }); - expect(calls).toEqual([["stop", "--backup=false"]]); + 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("forwards --no-backup, --project-id and --all", () => { - const { layer, calls } = setupLegacyStop(); + it.live( + "decodes a comma-separated string into an array field ([]string) for stop to proceed", + () => { + // Go's `newDecodeHook` wires `mapstructure.StringToSliceHookFunc(",")` + // unconditionally, so a plain string value for a `[]string` field like + // `additional_redirect_urls` decodes fine and must not block stop from + // reaching Docker. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + const { layer, child } = setup({ skipConfig: true, 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=demo", + "--all", + "--format", + "{{.ID}}", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("warns on stderr for a deprecated auth.external provider", () => { + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits + // this WARN via `Console.error` only when `goViperCompat` is set — verify + // legacy stop keeps that Go-parity behavior wired on. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + `project_id = "demo" + +[auth.external.slack] +enabled = true +`, + ); + const { layer } = setup({ skipConfig: true, route: defaultRoute() }); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); return Effect.gen(function* () { - yield* legacyStop({ - projectId: Option.some("abc"), - backup: true, - noBackup: true, - all: true, + yield* legacyStop(flags()); + expect(warnings.some((m) => m.includes('WARN: disabling deprecated "slack" provider'))).toBe( + true, + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }); + + it.live( + "fails and never spawns docker when config.toml has an unsupported db.major_version", + () => { + // Matches Go's default `stop` path, which runs `flags.LoadConfig` (config + // load + `Validate`) entirely before any Docker call + // (`internal/stop/stop.go:15-25` -> `pkg/config/config.go:882`) — a config + // Go rejects must fail `stop` before it touches containers, not just when + // reading `project_id`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + 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(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("does not run config Validate for --all (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: the `--all` branch never calls + // `flags.LoadConfig`, so an otherwise-invalid config.toml must not block it. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ all: Option.some(true) }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not run config Validate for --project-id (bypasses config entirely)", () => { + // `internal/stop/stop.go:15-25`: an explicit `--project-id` sets + // `Config.ProjectId` directly and never calls `flags.LoadConfig`. + const workdir = tempRoot.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n[db]\nmajor_version = 12\n', + ); + const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStop(flags({ projectId: Option.some("explicit") }))); + expect(Exit.isSuccess(exit)).toBe(true); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toContain("label=com.supabase.cli.project=explicit"); + }).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 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 }; + }, }); - expect(calls).toEqual([["stop", "--project-id", "abc", "--no-backup", "--all"]]); + 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("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", + 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/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(""); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index f4d5fd15f9..dfad46cac2 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -50,8 +50,8 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( workdir: string, projectRef: string, ) { - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 9ced346d5f..843059bf59 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -166,6 +166,22 @@ function resolveProfile( }); } +/** + * Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + * `os.Chdir(workdir)`s using the raw `--workdir`/`SUPABASE_WORKDIR` string, + * which can be relative (e.g. `.`) — but every later reader of the resolved + * workdir (including the `Config.ProjectId` cwd-basename default, `Eject`, + * `pkg/config/config.go:561-570`, run on every `Config.Load()` via + * `mergeDefaultValues`, `config.go:690-699`) reads `os.Getwd()`, the real + * ABSOLUTE directory, never the raw configured string. `os.Chdir(".")` is a + * no-op syscall-wise, so Go's `cwd` is unaffected by the flag/env value being + * relative. This resolves the flag/env value against the real process `cwd` + * the same way, so `LegacyCliConfig.workdir` is always absolute — matching + * Go's invariant that basename-ing it (e.g. `legacyResolveLocalProjectId`'s + * workdir-basename fallback) operates on a real directory name, not a + * relative-path fragment like `.` (which would sanitize to an empty project + * id and build a bare, all-projects-matching Docker label filter). + */ function resolveWorkdir( flagValue: Option.Option, envValue: string | undefined, @@ -175,10 +191,10 @@ function resolveWorkdir( ): Effect.Effect { return Effect.gen(function* () { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return flagValue.value; + return path.resolve(cwd, flagValue.value); } if (envValue !== undefined && envValue.length > 0) { - return envValue; + return path.resolve(cwd, envValue); } let current = cwd; // Walk up until we hit a directory containing supabase/config.toml or the FS root. diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index f6d8ca20f8..0bb042b435 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -278,6 +278,43 @@ describe("legacyCliConfigLayer", () => { ), ); + // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) always + // `os.Chdir`s the raw flag/env value, but every later reader — including the + // `Config.ProjectId` cwd-basename default (`Eject`, `pkg/config/config.go: + // 561-570`) — reads `os.Getwd()`, the real absolute directory, never the raw + // string. A relative `--workdir .`/`SUPABASE_WORKDIR=.` must therefore resolve + // to an absolute path here too, not stay `"."` (which would later basename to + // an empty project id). + it.effect("resolves a relative --workdir flag against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + ); + + it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(join(tempRoot, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + ); + + it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe(tempRoot); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + ); + + it.effect("keeps an absolute --workdir flag unchanged", () => + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + expect(config.workdir).toBe("/flag/workdir"); + }).pipe( + Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + ), + ); + it.effect("walks up from CWD looking for supabase/config.toml", () => { const projectRoot = join(tempRoot, "project"); const nested = join(projectRoot, "deep", "child"); 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-config-validate.parity.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts new file mode 100644 index 0000000000..d62db6a815 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts @@ -0,0 +1,266 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; +import { Effect, Exit, FileSystem, Path, Schema } from "effect"; + +import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +/** + * Cross-caller parity coverage: for a table of Go-parity misconfigurations, drives BOTH real + * pipelines — D (`legacyReadDbToml`, Effect/raw-TOML) and L (`legacyResolveLocalConfigValues`, + * `@supabase/config`-decoded) — and asserts they fail with the SAME shared error-message + * substring, since both now route through the single `legacyValidateResolvedConfig`. The two + * pipelines don't need byte-identical exception wrapping, just the same core Go-parity message + * text (`.toContain(...)` on both sides with the same expected string). + * + * D's harness replicates the `withConfig`/`read`/`failsWith` pattern from + * `legacy-db-config.toml-read.unit.test.ts` (file-local there, not exported — faithfully + * reproduced here rather than imported). L's harness replicates the `baseConfig`/`WORKDIR` + * pattern from `legacy-local-config-values.unit.test.ts` (same reasoning). + */ + +function withConfig(content: string) { + const dir = mkdtempSync(join(tmpdir(), "legacy-config-validate-parity-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(join(dir, "supabase", "config.toml"), content); + return dir; +} + +const readD = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +/** Drives D's real pipeline and asserts the failure message contains `message`. */ +function failsWithD(tomlLines: ReadonlyArray, message: string) { + return Effect.gen(function* () { + const dir = withConfig(tomlLines.join("\n")); + const exit = yield* readD(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain(message); + } + rmSync(dir, { recursive: true, force: true }); + }); +} + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const WORKDIR = "/tmp/legacy-config-validate-parity-test"; + +function baseConfig(overrides: Record = {}): ProjectConfig { + return decodeConfig({ project_id: "test", ...overrides }); +} + +/** Drives L's real pipeline and asserts the failure message contains `message`. */ +function failsWithL( + overrides: Record, + message: string, + document?: Readonly>, +) { + const config = baseConfig(overrides); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(message); +} + +interface ParityScenario { + readonly name: string; + readonly toml: ReadonlyArray; + readonly overrides: Record; + readonly document?: Readonly>; + readonly message: string; +} + +const scenarios: ReadonlyArray = [ + { + name: "db.port = 0", + toml: ["[db]", "port = 0"], + overrides: { db: { port: 0 } }, + message: "Missing required field in config: db.port", + }, + { + name: "db.major_version = 0", + toml: ["[db]", "major_version = 0"], + overrides: { db: { major_version: 0 } }, + message: "Missing required field in config: db.major_version", + }, + { + name: "db.major_version unsupported (16)", + toml: ["[db]", "major_version = 16"], + overrides: { db: { major_version: 16 } }, + message: "Failed reading config: Invalid db.major_version: 16.", + }, + { + name: "storage bucket name with an invalid pattern", + toml: ['[storage.buckets."bad#name"]'], + overrides: { storage: { buckets: { "bad#name": {} } } }, + message: + "Invalid Bucket name: bad#name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed.", + }, + { + name: "function slug with an invalid pattern", + toml: ["[functions.123]"], + overrides: { functions: { "123": {} } }, + message: + "Invalid Function name: 123. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens.", + }, + { + name: "edge_runtime.deno_version = 0", + toml: ["[edge_runtime]", "deno_version = 0"], + overrides: { edge_runtime: { deno_version: 0 } }, + message: "Missing required field in config: edge_runtime.deno_version", + }, + { + name: "edge_runtime.deno_version unsupported (3)", + toml: ["[edge_runtime]", "deno_version = 3"], + overrides: { edge_runtime: { deno_version: 3 } }, + message: "Failed reading config: Invalid edge_runtime.deno_version: 3.", + }, + { + name: "auth.site_url empty with auth enabled", + toml: ["[auth]", "enabled = true", 'site_url = ""'], + overrides: { auth: { enabled: true, site_url: "" } }, + message: "Missing required field in config: auth.site_url", + }, + { + name: "auth.captcha enabled without a provider", + toml: ["[auth.captcha]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true }, + }, + }, + message: "Missing required field in config: auth.captcha.provider", + }, + { + name: "auth.captcha enabled with a provider but no secret", + toml: ["[auth.captcha]", "enabled = true", 'provider = "hcaptcha"'], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + captcha: { enabled: true, provider: "hcaptcha" }, + }, + }, + message: "Missing required field in config: auth.captcha.secret", + }, + { + name: "auth.hook.* with a badly-formatted secret", + toml: [ + "[auth.hook.custom_access_token]", + "enabled = true", + 'uri = "https://example.test/hook"', + 'secrets = "not-a-valid-secret"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + }, + }, + }, + // D's assertion goes through `JSON.stringify(exit.cause)`, which backslash-escapes the + // message's embedded double quotes — trim the substring to the quote-free prefix, same + // convention D's own suite uses for this message. + message: "auth.hook.custom_access_token.secrets must be formatted as", + }, + { + name: "auth.mfa.* enroll_enabled without verify_enabled", + toml: ["[auth.mfa.totp]", "enroll_enabled = true", "verify_enabled = false"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + mfa: { totp: { enroll_enabled: true, verify_enabled: false } }, + }, + }, + message: "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + }, + { + name: "auth.third_party.* enabled without its required field", + toml: ["[auth.third_party.firebase]", "enabled = true"], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { firebase: { enabled: true } }, + }, + }, + message: "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + }, + { + name: "auth.third_party.* more than one provider enabled", + toml: [ + "[auth.third_party.firebase]", + "enabled = true", + 'project_id = "proj"', + "[auth.third_party.auth0]", + "enabled = true", + 'tenant = "tenant"', + ], + overrides: { + auth: { + enabled: true, + site_url: "http://localhost:3000", + third_party: { + firebase: { enabled: true, project_id: "proj" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }, + message: "Invalid config: Only one third_party provider allowed to be enabled at a time.", + }, + { + name: "auth.email.smtp present table missing a required field", + // Both pipelines read every smtp field straight off the raw TOML/document rather than a + // schema-decoded, always-defaulted value (Go's presence-based `enabled` default, + // config.go:743-748) — L needs the raw `document` (5th param) for this, matching D's raw + // smol-toml document. + toml: ["[auth.email.smtp]", 'user = "u"'], + overrides: { auth: { enabled: true, site_url: "http://localhost:3000" } }, + document: { auth: { email: { smtp: { user: "u" } } } }, + message: "Missing required field in config: auth.email.smtp.host", + }, + { + name: "experimental.pgdelta.format_options invalid JSON", + toml: ["[experimental.pgdelta]", 'format_options = "{not json"'], + overrides: { experimental: { pgdelta: { format_options: "{not json" } } }, + message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + }, +]; + +// Explicitly SKIPPED (only one caller runs the branch, or the branch isn't exercised the same +// way by both — see the module header in `legacy-config-validate.ts` for the full explicitly +// out-of-scope list): +// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared +// validator (`LegacyConfigValidationInput` has no fields for these at all). +// - `api.tls`, `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. +// - `experimental.webhooks` — L reads webhooks presence from a raw `document` that D's +// `legacyReadDbToml` doesn't thread through `legacyValidateResolvedConfig` at all (D has no +// `experimental` presence-based input field for this — verified: `legacy-db-config.toml-read.ts` +// never sets `experimental.webhooksPresent`/`webhooksEnabled` on its `LegacyExperimentalInput`), +// so this branch is D-unreachable and skipped here too. +describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { + for (const scenario of scenarios) { + it.effect(`${scenario.name}: D and L fail with the same message`, () => + Effect.gen(function* () { + yield* failsWithD(scenario.toml, scenario.message); + failsWithL(scenario.overrides, scenario.message, scenario.document); + }), + ); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts new file mode 100644 index 0000000000..e7d6fd93a0 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -0,0 +1,761 @@ +import { isAbsolute, join } from "node:path"; + +import { legacyGoUrlParse } from "./legacy-storage-url.ts"; + +/** + * Single home for Go's `Config.Validate` parity (`apps/cli-go/pkg/config/config.go:989-1192`), + * consolidating the two independent TypeScript ports of that logic: + * + * - **D** = `legacy-db-config.toml-read.ts` — raw smol-toml document + `EnvLookup`, + * Effect-based, fails with `LegacyDbConfigLoadError`. Feeds ~15 db/migration commands via + * `legacy-db-config.layer.ts`. + * - **L** = `legacy-local-config-values.ts` — decoded `@supabase/config` `ProjectConfig`, + * synchronous `node:fs`, throws plain `Error`. Feeds `status/status.values.ts` and + * `stop/stop.handler.ts`. + * + * **This file is the SINGLE home for `Config.Validate` parity going forward. + * Per-command reimplementations of any branch below are forbidden** — hoist here instead, + * per `apps/cli/AGENTS.md`'s "Hoist Before You Duplicate" policy. + * + * ## Status of this commit + * + * {@link legacyValidateResolvedConfig} is now IMPLEMENTED and fully wired into BOTH callers: L + * (`legacy-local-config-values.ts`'s `legacyResolveLocalConfigValues`) and D + * (`legacy-db-config.toml-read.ts`'s `legacyReadDbToml`) each build a + * {@link LegacyConfigValidationInput} from their own decoded config (a `ProjectConfig` + raw + * `document` for L, a raw smol-toml document + `EnvLookup` for D) and call this function once, + * at the correct Go position. Wiring D through this module also fixed D's `db.major_version + * === 0` divergence (D used to fall through to the generic invalid-value message; it now + * throws the same "Missing required field in config: db.major_version" as Go and as L already + * did). + * + * ## Full eventual scope: every `Config.Validate` branch this module owns + * + * In Go's exact `Validate()` order (`config.go:989-1192`), first-failure-wins: + * + * | Go line(s) | Check | + * |--------------------------------|-------| + * | 990-991 | `project_id` required | + * | 1006-1027 | `api.port` / `api.tls.{cert,key}_path` presence (the actual file reads stay per-caller I/O) | + * | 1031-1062 | `db.port`, `db.major_version` (0 / 12 / 13-17 switch) | + * | 1064-1068, pattern @ 1549-1554 | `storage.buckets.*` names vs `LEGACY_BUCKET_NAME_PATTERN` | + * | 1070-1079 | `studio.port` / `studio.api_url` (L-only — D has no studio section) | + * | 1081-1085 | `local_smtp.port` (L-only) | + * | 1087-1153 | `auth.*` sub-sequence, in order: site_url (1088-1090); captcha enum + presence (1099-1109, enum itself decode-time per `auth.go:58-71`); signing_keys read (1110-1116, caller-side I/O); passkey/webauthn (1117-1134); hooks (1136-1138, checks @ 1453-1521, vs `LEGACY_HOOK_SECRET_PATTERN`); mfa (1139-1141, checks @ 1523-1534); email template/notification content-vs-content_path (1293-1323, caller-side I/O) + smtp (1325-1344); third_party (1151-1153, checks @ 1635-1683, vs `LEGACY_CLERK_DOMAIN_PATTERN`) | + * | 1159-1163, pattern @ 1539-1544 | `functions.*` slugs vs `LEGACY_FUNCTION_SLUG_PATTERN` | + * | 1164-1173 | `edge_runtime.deno_version` (0 / 1 / 2 switch) | + * | (decode-time enum) | `analytics.backend` must be `postgres`/`bigquery` | + * | 1175-1187 | `analytics.gcp_*` fields, gated on `backend === "bigquery"` | + * | 1846-1854 | `experimental.webhooks` / `experimental.pgdelta.format_options` | + * + * ## Explicitly OUT of scope forever (D-only, NEVER part of this module) + * + * - `remotes[*].project_id` pattern (`config.go:997-1001`, vs `LEGACY_PROJECT_REF_PATTERN`) — + * D's own remote-merge-phase check (`findInvalidRemoteProjectId`), never shared with L. + * - `auth.sms` (`config.go:1145-1147`/`1348-1417`) — stays 100% inline in D. + * - `auth.external` (`config.go:1148-1150`/`1419-1451`) — stays 100% inline in D. + * - `auth.jwt_secret` length check (`apikeys.go:43-73`, `generateAPIKeys`) — each caller's own + * key-generation flow (D and L both already implement this separately), not part of + * `Config.Validate`'s pure-check set. + * + * `legacyExpandEnv` also stays in D (env-substitution machinery, not a validation leaf). + * + * ## Known ordering tradeoff (accepted — do not "fix") + * + * Go's real auth-block order is site_url → captcha → signing_keys[IO] → passkey → hooks → mfa → + * email[IO]+smtp → **sms → external** → third_party. Since sms/external are D-only and never + * part of this module, but third_party IS shared, D cannot call + * {@link legacyValidateResolvedConfig} in a way that preserves relative ordering across the + * sms/external ↔ third_party boundary without complex multi-phase calls. Decision (applies once + * D is wired up in a follow-up commit): D calls {@link legacyValidateResolvedConfig} ONCE with + * the full input (including third_party), positioned after D's own signing-keys and + * email-template I/O reads; D's inline sms/external checks then run AFTER that single call + * succeeds. This means: if third_party is broken, its error surfaces (matching Go); D's + * sms/external checks never run in that case. The only real behavior change from today: for the + * (untested, unrealistic) case where sms/external AND third_party are BOTH simultaneously + * broken in the same config.toml, Go/today's-D would report the sms/external error first, but + * the refactored D reports third_party's error first, since third_party is checked inside the + * single earlier shared call. This is an accepted, narrow, documented parity gap. + * + * The same category of tradeoff now also applies to L: `legacyResolveLocalConfigValues` calls + * {@link legacyValidateResolvedConfig} exactly ONCE, at the very end, after every value this + * module needs has been derived — including L's 3 I/O reads (signing keys, `api.tls` cert/key, + * email template/notification content), which stay at their original textual position (per-caller + * I/O, same as D's). Every pure check this module owns is therefore checked in Go's exact + * relative order against every OTHER pure check, but an I/O read that in L's source sits + * between two pure sections (e.g. the signing-keys read sits between the captcha check and the + * passkey/hooks/mfa/email/smtp/third_party checks) now effectively runs BEFORE any of those + * later pure checks, rather than interleaved at its original relative position — the same + * narrow, accepted, documented tradeoff, not something to "fix" by splitting this function into + * multiple calls. Every existing test constructs exactly one validation failure at a time, so + * this has zero effect on any real test. + */ + +// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 lowercase +// ASCII letters. Exported from this module (was private in D before this relocation) as the +// canonical home; D's `findInvalidRemoteProjectId` is today the only consumer — the +// `remotes[*].project_id` check itself stays D-only forever, see the module header above. +export const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; + +// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). +// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key +// during config load (`config.go:898-903`), aborting before any db command when a +// name does not match. The source string is reused verbatim in the error message via +// `.source` so it byte-matches Go's `bucketNamePattern.String()`. Used by both D +// (`legacy-db-config.toml-read.ts`) and L (`legacy-local-config-values.ts`), and internally by +// {@link legacyValidateResolvedConfig}'s storage-bucket-names step (`config.go:1064-1068`). +export const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; + +// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` +// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load +// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused +// in the message so it byte-matches Go's `funcSlugPattern.String()`. Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s function-slugs step (`config.go:1159-1163`). +export const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; + +// Go's `hookSecretPattern` (`apps/cli-go/pkg/config/config.go:1436`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s hooks step (`config.go:1453-1521`). +export const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; + +// Go's `clerkDomainPattern` (`apps/cli-go/pkg/config/config.go:1553`). Used by both D and L +// (same reason as {@link LEGACY_BUCKET_NAME_PATTERN} above), and internally by +// {@link legacyValidateResolvedConfig}'s third_party step (`config.go:1635-1683`). +export const LEGACY_CLERK_DOMAIN_PATTERN = + /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; + +// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under +// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty +// string is `false`, and any other value is a parse error. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + +/** + * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly + * typed decode). Returns the bool, or `undefined` for a malformed value (which Go + * surfaces as a `failed to parse config` error). + * + * Used by both D (`legacy-db-config.toml-read.ts`'s `resolveBool`/`resolveBoolOrFail`) and + * L (`legacy-local-config-values.ts`'s `legacyEnvOverrideBool`) for their `SUPABASE_*` + * bool-flavored env overrides and TOML bool decoding. + */ +export function legacyParseGoBool(value: string): boolean | undefined { + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; + return undefined; +} + +/** + * Thrown by {@link legacyValidateResolvedConfig}. Deliberately does NOT override `.name` in a + * constructor — it stays the inherited `"Error"` — so `.toString()`/`.name`/`instanceof Error` + * checks are indistinguishable from a plain `new Error(message)`. Both D and L's existing + * callers/tests observe only `.message` (via `cause instanceof Error ? cause.message : ...` or + * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class + * is a byte-identical, purely internal refactor. + */ +export class LegacyConfigValidateError extends Error {} + +/** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ +export interface LegacyApiInput { + readonly enabled: boolean; + readonly port: number; + readonly tls: { + readonly enabled: boolean; + readonly certPath: string | undefined; + readonly keyPath: string | undefined; + }; +} + +/** `[db]`, post-env-override. Required — Go validates `db.port`/`db.major_version` unconditionally. */ +export interface LegacyDbInput { + readonly port: number; + readonly majorVersion: number; +} + +/** `[studio]`, post-env-override. L-only — D has no studio section. */ +export interface LegacyStudioInput { + readonly enabled: boolean; + readonly port: number; + readonly apiUrl: string; +} + +/** `[local_smtp]` (Go's `Inbucket`), post-env-override. L-only. */ +export interface LegacyLocalSmtpInput { + readonly enabled: boolean; + readonly port: number; +} + +/** `[auth.captcha]`. `provider` is deliberately `string | undefined`, not a narrow union — see + * divergence #2 in the module's port plan: D passes a raw, untyped TOML string (the enum check + * is live for D); L's `@supabase/config`-decoded value is already schema-narrowed to + * `"hcaptcha" | "turnstile" | undefined` before this function ever sees it, making the branch + * dead-but-harmless for L specifically, while still needing the same widened type to keep this + * field honest and reusable across both callers. + */ +export interface LegacyCaptchaInput { + readonly enabled: boolean; + readonly provider: string | undefined; + readonly secret: string | undefined; +} + +/** `[auth.passkey]` + `[auth.webauthn]`. Present iff `passkey.enabled === true`. */ +export interface LegacyPasskeyInput { + readonly webauthnPresent: boolean; + readonly rpId: string | undefined; + readonly rpOrigins: ReadonlyArray | undefined; +} + +/** One enabled `[auth.hook.]` entry. Caller pre-filters to enabled-only and pre-orders + * per Go's fixed hook-type iteration order (`config.go:1453-1485`). */ +export interface LegacyHookInput { + readonly type: + | "mfa_verification_attempt" + | "password_verification_attempt" + | "custom_access_token" + | "send_sms" + | "send_email" + | "before_user_created"; + /** Post-env-expand; `""` = absent. */ + readonly uri: string; + /** Post-env-expand; `""` = absent. */ + readonly secrets: string; +} + +/** One `[auth.mfa.]` entry. Caller pre-orders totp, phone, web_authn. */ +export interface LegacyMfaFactorInput { + readonly label: "totp" | "phone" | "web_authn"; + readonly enrollEnabled: boolean; + readonly verifyEnabled: boolean; +} + +/** `[auth.email.smtp]`. Present iff the raw TOML table itself is present (Go's presence-based + * `enabled` default, `config.go:743-748` — NOT the decoded, always-defaulted value). */ +export interface LegacySmtpInput { + readonly enabled: boolean; + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; +} + +/** One enabled `[auth.third_party.]` entry. Caller pre-filters to enabled-only and + * pre-orders per Go's fixed provider order (firebase, auth0, cognito, clerk, workos). */ +export interface LegacyThirdPartyInput { + readonly provider: "firebase" | "auth0" | "cognito" | "clerk" | "workos"; + /** `project_id` / `tenant` / `user_pool_id` / `domain` / `issuer_url`, per provider. */ + readonly requiredField: string; + /** cognito's second required field only. */ + readonly cognitoUserPoolRegion?: string; +} + +/** `[auth]`. Present in {@link LegacyConfigValidationInput} iff auth is enabled — matches Go's + * `if c.Auth.Enabled` gate wrapping this entire sub-sequence (`config.go:1087-1153`). */ +export interface LegacyAuthInput { + readonly siteUrl: string; + readonly captcha?: LegacyCaptchaInput; + readonly passkey?: LegacyPasskeyInput; + readonly hooks: ReadonlyArray; + readonly mfa: ReadonlyArray; + readonly smtp?: LegacySmtpInput; + readonly thirdParty: ReadonlyArray; +} + +/** `[analytics]`, post-env-override. Unconditional entry — internally gated on `enabled` + + * `backend === "bigquery"`. `backend` is `string | undefined` for the same dead-but-harmless-for-L + * reason as {@link LegacyCaptchaInput.provider} — see divergence #2. */ +export interface LegacyAnalyticsInput { + readonly enabled: boolean; + readonly backend: string | undefined; + readonly gcpProjectId: string; + readonly gcpProjectNumber: string; + readonly gcpJwtPath: string; +} + +/** `[experimental]`. Unconditional entry — internally gated. `webhooksPresent`/`webhooksEnabled` + * hinge on TOML-section PRESENCE (not the decoded, always-defaulted `enabled` value) — see + * `config.go:1846-1854` and the callers' own doc comments for why. */ +export interface LegacyExperimentalInput { + readonly webhooksPresent?: boolean; + readonly webhooksEnabled?: boolean; + readonly pgdeltaFormatOptions: string; +} + +/** + * Normalized POST-env-override primitives mirroring Go's decoded config, for VALIDATED fields + * only. Every section is OPTIONAL — an absent section means "this caller doesn't run that Go + * branch, skip it" (e.g. D omits `studio`/`localSmtp` entirely; both D and L omit `auth` when + * auth is disabled). See the module header for the full ported-branch table and out-of-scope + * list. + */ +export interface LegacyConfigValidationInput { + /** L only — D's `project_id` isn't part of `Config.Validate`'s shared surface. */ + readonly projectId?: string; + /** L only — D has no `[api]` section. */ + readonly api?: LegacyApiInput; + /** Both, unconditional in Go. */ + readonly db: LegacyDbInput; + /** Both, unconditional (`[]` = none). */ + readonly storageBucketNames: ReadonlyArray; + /** L only. */ + readonly studio?: LegacyStudioInput; + /** L only. */ + readonly localSmtp?: LegacyLocalSmtpInput; + /** Both, present iff auth is enabled. */ + readonly auth?: LegacyAuthInput; + /** Both, unconditional (`[]` = none). */ + readonly functionSlugs: ReadonlyArray; + /** Both, unconditional. */ + readonly edgeRuntimeDenoVersion: number; + /** Both, unconditional entry (internally gated). */ + readonly analytics: LegacyAnalyticsInput; + /** Both, unconditional entry (internally gated). */ + readonly experimental: LegacyExperimentalInput; +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Runs every `Config.Validate` branch this module owns (see the module header's table), in + * Go's exact order, first-failure-wins. Pure — no I/O, no Effect. Callers own their own + * per-section I/O reads (signing keys, `api.tls` cert/key, email template/notification content) + * at the correct Go position themselves, using the pure helpers exported below. + */ +export function legacyValidateResolvedConfig(input: LegacyConfigValidationInput): void { + // config.go:990-991 — checked FIRST, before every other field. + if (input.projectId !== undefined && input.projectId.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: project_id"); + } + + // config.go:1006-1027 — api.port / api.tls.{cert,key}_path, gated on api.enabled. The actual + // cert/key file reads are caller-side I/O (see legacyResolveApiTlsPath below); this only + // checks the "exactly one of cert/key set" presence rule. + if (input.api?.enabled) { + if (input.api.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: api.port"); + } + if (input.api.tls.enabled) { + const hasCert = input.api.tls.certPath !== undefined && input.api.tls.certPath.length > 0; + const hasKey = input.api.tls.keyPath !== undefined && input.api.tls.keyPath.length > 0; + if (hasCert && !hasKey) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.key_path"); + } + if (hasKey && !hasCert) { + throw new LegacyConfigValidateError("Missing required field in config: api.tls.cert_path"); + } + } + } + + // config.go:1031-1033 — db.port, unconditional, no `enabled` gate. + if (input.db.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.port"); + } + // config.go:1034-1062 — db.major_version switch: 0 / 12 have dedicated messages, 13/14/15/17 + // are supported, anything else is the generic invalid-value message. + if (input.db.majorVersion === 0) { + throw new LegacyConfigValidateError("Missing required field in config: db.major_version"); + } + if (input.db.majorVersion === 12) { + throw new LegacyConfigValidateError( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + if (![13, 14, 15, 17].includes(input.db.majorVersion)) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid db.major_version: ${input.db.majorVersion}.`, + ); + } + + // config.go:1064-1068, pattern @ 1549-1554 — every [storage.buckets.*] key, unconditional. + for (const name of input.storageBucketNames) { + if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { + throw new LegacyConfigValidateError( + `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, + ); + } + } + + // config.go:1070-1079 — studio.port / studio.api_url, gated on studio.enabled. L-only. + if (input.studio?.enabled) { + if (input.studio.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: studio.port"); + } + try { + legacyGoUrlParse(input.studio.apiUrl); + } catch (cause) { + throw new LegacyConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); + } + } + + // config.go:1081-1085 — local_smtp.port, gated on local_smtp.enabled. L-only. + if (input.localSmtp?.enabled && input.localSmtp.port === 0) { + throw new LegacyConfigValidateError("Missing required field in config: local_smtp.port"); + } + + // config.go:1087-1153 — the auth.* sub-sequence, all inside `if c.Auth.Enabled`. + if (input.auth !== undefined) { + const auth = input.auth; + + // config.go:1088-1090 — auth.site_url. + if (auth.siteUrl.length === 0) { + throw new LegacyConfigValidateError("Missing required field in config: auth.site_url"); + } + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha. The provider enum check runs FIRST, + // regardless of `enabled` (it's actually a decode-time check in Go, reproduced here so both + // callers see it from one place); only then does the `enabled`-gated presence check run. + if (auth.captcha !== undefined) { + const provider = auth.captcha.provider; + if ( + provider !== undefined && + provider.length > 0 && + provider !== "hcaptcha" && + provider !== "turnstile" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + } + if (auth.captcha.enabled) { + if (auth.captcha.provider === undefined) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.provider", + ); + } + if (auth.captcha.secret === undefined || auth.captcha.secret.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.captcha.secret", + ); + } + } + } + + // config.go:1110-1116 — signing_keys read is caller-side I/O, not part of this function. + + // config.go:1117-1134 — auth.passkey / auth.webauthn. Caller only builds `passkey` when + // `[auth.passkey] enabled` is true. + if (auth.passkey !== undefined) { + if (!auth.passkey.webauthnPresent) { + throw new LegacyConfigValidateError( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + } + if (auth.passkey.rpId === undefined || auth.passkey.rpId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_id", + ); + } + if (auth.passkey.rpOrigins === undefined || auth.passkey.rpOrigins.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.webauthn.rp_origins", + ); + } + } + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filtered to + // enabled-only and pre-ordered per Go's fixed hook-type iteration order. + for (const hook of auth.hooks) { + if (hook.uri.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.uri`, + ); + } + const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(hook.uri)?.[1] ?? "").toLowerCase(); + if (scheme === "http" || scheme === "https") { + if (hook.secrets.length === 0) { + throw new LegacyConfigValidateError( + `Missing required field in config: auth.hook.${hook.type}.secrets`, + ); + } + for (const secret of hook.secrets.split("|")) { + if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, + ); + } + } + } else if (scheme === "pg-functions") { + if (hook.secrets.length > 0) { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.secrets is unsupported for pg-functions URI`, + ); + } + } else { + throw new LegacyConfigValidateError( + `Invalid hook config: auth.hook.${hook.type}.uri should be a HTTP, HTTPS, or pg-functions URI`, + ); + } + } + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, caller pre-ordered totp/phone/web_authn. + for (const factor of auth.mfa) { + if (factor.enrollEnabled && !factor.verifyEnabled) { + throw new LegacyConfigValidateError( + `Invalid MFA config: auth.mfa.${factor.label}.enroll_enabled requires verify_enabled`, + ); + } + } + + // config.go:1293-1323 — email template/notification content read + exclusivity is + // caller-side, via legacyResolveEmailTemplateContentPath below. + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + if (auth.smtp !== undefined && auth.smtp.enabled) { + if (auth.smtp.host.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.host", + ); + } + if (auth.smtp.port === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.port", + ); + } + if (auth.smtp.user.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.user", + ); + } + if (auth.smtp.pass.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.pass", + ); + } + if (auth.smtp.adminEmail.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: auth.email.smtp.admin_email", + ); + } + } + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, caller pre-filtered to + // enabled-only and pre-ordered firebase, auth0, cognito, clerk, workos. Each provider's + // required field(s) are checked as encountered; the "more than one enabled" check runs only + // after every entry has individually validated. + for (const thirdParty of auth.thirdParty) { + switch (thirdParty.provider) { + case "firebase": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + } + break; + } + case "auth0": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", + ); + } + break; + } + case "cognito": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", + ); + } + if ( + thirdParty.cognitoUserPoolRegion === undefined || + thirdParty.cognitoUserPoolRegion.length === 0 + ) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + } + break; + } + case "clerk": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk is enabled but without a domain.", + ); + } + if (!LEGACY_CLERK_DOMAIN_PATTERN.test(thirdParty.requiredField)) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", + ); + } + break; + } + case "workos": { + if (thirdParty.requiredField.length === 0) { + throw new LegacyConfigValidateError( + "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", + ); + } + break; + } + } + } + if (auth.thirdParty.length > 1) { + throw new LegacyConfigValidateError( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + } + } + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + for (const slug of input.functionSlugs) { + if (!LEGACY_FUNCTION_SLUG_PATTERN.test(slug)) { + throw new LegacyConfigValidateError( + `Invalid Function name: ${slug}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, + ); + } + } + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled. + if (input.edgeRuntimeDenoVersion === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: edge_runtime.deno_version", + ); + } + if (input.edgeRuntimeDenoVersion !== 1 && input.edgeRuntimeDenoVersion !== 2) { + throw new LegacyConfigValidateError( + `Failed reading config: Invalid edge_runtime.deno_version: ${input.edgeRuntimeDenoVersion}.`, + ); + } + + // Decode-time enum (`LogflareBackend.UnmarshalText`, config.go:60-65) — reproduced here so + // both callers' env-override paths (which bypass their own decode-time schema guard) see it. + const backend = input.analytics.backend; + if ( + backend !== undefined && + backend.length > 0 && + backend !== "postgres" && + backend !== "bigquery" + ) { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + ); + } + // config.go:1175-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". + if (input.analytics.enabled && backend === "bigquery") { + if (input.analytics.gcpProjectId.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_id", + ); + } + if (input.analytics.gcpProjectNumber.length === 0) { + throw new LegacyConfigValidateError( + "Missing required field in config: analytics.gcp_project_number", + ); + } + if (input.analytics.gcpJwtPath.length === 0) { + throw new LegacyConfigValidateError( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + } + } + + // config.go:1847-1848 — experimental.webhooks, hinges on TOML-section PRESENCE, not the + // decoded (always-defaulted) `enabled` value. + if (input.experimental.webhooksPresent === true && input.experimental.webhooksEnabled !== true) { + throw new LegacyConfigValidateError( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + } + // config.go:1850-1851 — experimental.pgdelta.format_options, must be valid JSON when set. + if ( + input.experimental.pgdeltaFormatOptions !== "" && + !isValidJson(input.experimental.pgdeltaFormatOptions) + ) { + throw new LegacyConfigValidateError( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + } +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +// ── signing keys (config.go:1110-1116, path rule config.go:877-878 filepath.IsAbs guard) ── + +/** Absolute → verbatim; relative → join(workdir, "supabase", p). */ +export function legacyResolveSigningKeysPath(workdir: string, signingKeysPath: string): string { + return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +} + +/** `failed to read signing keys: ${msg(cause)}` */ +export function legacySigningKeysReadErrorMessage(cause: unknown): string { + return `failed to read signing keys: ${messageOf(cause)}`; +} + +/** `failed to decode signing keys: ${msg(cause)}` */ +export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { + return `failed to decode signing keys: ${messageOf(cause)}`; +} +// D only asserts Array.isArray(JSON.parse(text)); L further decodes into LegacyJwk[] to sign +// with the first key — that JWK-specific decode/signing logic stays in L, unrelated to parity. + +// ── email template / notification (config.go:1293-1323) ── + +/** + * Pure exclusivity decision + path to read for one template/notification entry. Throws + * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and + * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to + * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even + * when `content` is ALSO set) always wins — Go does not reject "both set", `content_path` + * silently wins/overwrites. + * + * `base` is caller-resolved: TEMPLATE section → workdir; NOTIFICATION section → + * join(workdir, "supabase") (this asymmetry is real, intentional Go behavior — config.go's own + * FIXME comment flags it, do not "fix" it). + */ +export function legacyResolveEmailTemplateContentPath(args: { + readonly section: "template" | "notification"; + readonly name: string; + /** Post-env-expand; `""` = absent. */ + readonly contentPath: string; + /** Raw `content` key present in the TOML document. */ + readonly contentPresent: boolean; + readonly base: string; +}): string | undefined { + if (args.contentPath.length === 0) { + if (args.contentPresent) { + throw new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ); + } + return undefined; + } + return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); +} + +/** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ +export function legacyEmailContentPathReadErrorMessage( + section: "template" | "notification", + name: string, + cause: unknown, +): string { + return `Invalid config for auth.email.${section}.${name}.content_path: ${messageOf(cause)}`; +} + +// ── api.tls cert/key (config.go:1016-1026, path rule ~961-965, NO isAbsolute guard) ── + +/** Unconditional join(workdir, "supabase", p) — Go's path.Join absorbs a leading "/" too. */ +export function legacyResolveApiTlsPath(workdir: string, p: string): string { + return join(workdir, "supabase", p); +} + +/** `failed to read TLS cert: ${msg(cause)}` */ +export function legacyApiTlsCertReadErrorMessage(cause: unknown): string { + return `failed to read TLS cert: ${messageOf(cause)}`; +} + +/** `failed to read TLS key: ${msg(cause)}` */ +export function legacyApiTlsKeyReadErrorMessage(cause: unknown): string { + return `failed to read TLS key: ${messageOf(cause)}`; +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts new file mode 100644 index 0000000000..f5b4635620 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-config-validate.unit.test.ts @@ -0,0 +1,1020 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_BUCKET_NAME_PATTERN, + LEGACY_CLERK_DOMAIN_PATTERN, + LEGACY_FUNCTION_SLUG_PATTERN, + LEGACY_HOOK_SECRET_PATTERN, + LEGACY_PROJECT_REF_PATTERN, + type LegacyAuthInput, + type LegacyConfigValidationInput, + legacyParseGoBool, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; + +// Starter suite for the symbols relocated from `legacy-db-config.toml-read.ts` in an earlier +// commit (see the module header in `legacy-config-validate.ts`). The bulk of `Config.Validate` +// behavioral coverage — direct calls to `legacyValidateResolvedConfig`, covering every branch +// this module owns regardless of which caller (D or L) exercises it — lives further down this +// file; it was consolidated here from `legacy-local-config-values.unit.test.ts`, where it used +// to be exercised only indirectly through `legacyResolveLocalConfigValues`. + +describe("legacyParseGoBool", () => { + it("accepts Go's strconv.ParseBool true forms", () => { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(legacyParseGoBool(value)).toBe(true); + } + }); + + it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { + expect(legacyParseGoBool(value)).toBe(false); + } + }); + + it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { + expect(legacyParseGoBool("yes")).toBeUndefined(); + expect(legacyParseGoBool("2")).toBeUndefined(); + }); +}); + +describe("LEGACY_PROJECT_REF_PATTERN", () => { + it("matches a valid 20-character lowercase project ref", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("abcdefghijklmnopqrst")).toBe(true); + }); + + it("rejects refs of the wrong length or case", () => { + expect(LEGACY_PROJECT_REF_PATTERN.test("short")).toBe(false); + expect(LEGACY_PROJECT_REF_PATTERN.test("ABCDEFGHIJKLMNOPQRST")).toBe(false); + }); +}); + +describe("LEGACY_BUCKET_NAME_PATTERN", () => { + it("matches Go-legal bucket name characters", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); + }); + + it("rejects characters outside Go's bucketNamePattern", () => { + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); + expect(LEGACY_BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); + }); +}); + +describe("LEGACY_FUNCTION_SLUG_PATTERN", () => { + it("matches a valid function slug (letters, digits, _ and -)", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("my-function")).toBe(true); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("function_1")).toBe(true); + }); + + it("rejects a slug that doesn't start with a letter", () => { + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("123")).toBe(false); + expect(LEGACY_FUNCTION_SLUG_PATTERN.test("1bad")).toBe(false); + }); +}); + +describe("LEGACY_HOOK_SECRET_PATTERN", () => { + it("matches a valid v1,whsec_ secret", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); + }); + + it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + expect(LEGACY_HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); + }); +}); + +describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { + it("matches a valid clerk.example.com domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("clerk.example.com")).toBe(true); + }); + + it("matches a valid .clerk.accounts.dev domain", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); + }); + + it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + expect(LEGACY_CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); + }); +}); + +/** + * A trivially-passing full input. Every test below spreads/overrides only the field(s) its + * check cares about, matching the fixture-building style of `legacy-local-config-values.unit + * .test.ts`'s own `baseConfig()` helper. + */ +function minimalInput( + overrides: Partial = {}, +): LegacyConfigValidationInput { + return { + db: { port: 5432, majorVersion: 17 }, + storageBucketNames: [], + functionSlugs: [], + edgeRuntimeDenoVersion: 2, + analytics: { + enabled: false, + backend: undefined, + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + experimental: { pgdeltaFormatOptions: "" }, + ...overrides, + }; +} + +/** A trivially-passing `[auth]` section — auth enabled, nothing else configured. */ +function minimalAuthInput(overrides: Partial = {}): LegacyAuthInput { + return { + siteUrl: "http://localhost:3000", + hooks: [], + mfa: [], + thirdParty: [], + ...overrides, + }; +} + +// Moved from `legacy-local-config-values.unit.test.ts`: these describe blocks exercise checks +// that now live entirely inside `legacyValidateResolvedConfig` and can be phrased as direct +// calls with a hand-built `LegacyConfigValidationInput` — no `ProjectConfig`/schema decode, no +// env-override machinery, no file I/O, no `document` threading. Everything that still needs +// one of those (value derivation, env-override mechanics, the 3 I/O checks' actual file reads) +// stays in `legacy-local-config-values.unit.test.ts`. +describe("legacyValidateResolvedConfig", () => { + // config.go:1034-1062 — db.major_version switch. The env-override + // (SUPABASE_DB_MAJOR_VERSION) variants stay in legacy-local-config-values.unit.test.ts. + describe("db.major_version", () => { + it("rejects a configured major_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 0 } })), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), + ).toThrow("Postgres version 12.x is unsupported."); + }); + + it.each([13, 14, 15, 17])("accepts the supported major_version %d", (majorVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion } })), + ).not.toThrow(); + }); + + it("rejects an unsupported major_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 16 } })), + ).toThrow("Failed reading config: Invalid db.major_version: 16."); + }); + }); + + // config.go:1064-1068, pattern @ 1549-1554 — unconditional, no storage.enabled-style gate. + describe("storage.buckets", () => { + it("rejects a bucket name Go's ValidateBucketName refuses", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), + ).toThrow("Invalid Bucket name: bad/name."); + }); + + it("does not throw for a valid bucket name", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ storageBucketNames: ["avatars.public"] })), + ).not.toThrow(); + }); + + it("does not throw when no buckets are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1164-1173 — edge_runtime.deno_version switch, unconditional, not gated on + // edge_runtime.enabled (there is no such field on LegacyConfigValidationInput at all). The + // env-override (SUPABASE_EDGE_RUNTIME_DENO_VERSION) variants stay in + // legacy-local-config-values.unit.test.ts. + describe("edge_runtime.deno_version", () => { + it("rejects a configured deno_version of 0", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + + it.each([1, 2])("accepts the supported deno_version %d", (denoVersion) => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: denoVersion })), + ).not.toThrow(); + }); + + it("rejects an unsupported deno_version with the generic invalid-value message", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 3 })), + ).toThrow("Failed reading config: Invalid edge_runtime.deno_version: 3."); + }); + + it("rejects an invalid deno_version even when edge_runtime is disabled", () => { + // There is no `edgeRuntime.enabled`-style gate on `LegacyConfigValidationInput` at + // all — this is identical to the "rejects a configured deno_version of 0" case above, + // which is itself the point: Go never gates this check on edge_runtime.enabled. + expect(() => + legacyValidateResolvedConfig(minimalInput({ edgeRuntimeDenoVersion: 0 })), + ).toThrow("Missing required field in config: edge_runtime.deno_version"); + }); + }); + + // config.go:1174-1187 — analytics.gcp_*, gated on enabled && backend === "bigquery". The + // env-override (SUPABASE_ANALYTICS_*) variants stay in legacy-local-config-values.unit.test.ts. + describe("analytics (BigQuery backend required fields)", () => { + it("rejects an enabled bigquery backend without gcp_project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_id"); + }); + + it("rejects an enabled bigquery backend without gcp_project_number", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).toThrow("Missing required field in config: analytics.gcp_project_number"); + }); + + it("rejects an enabled bigquery backend without gcp_jwt_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "", + }, + }), + ), + ).toThrow( + "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", + ); + }); + + it("does not throw when an enabled bigquery backend has all three GCP fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "bigquery", + gcpProjectId: "proj", + gcpProjectNumber: "123", + gcpJwtPath: "gcp.json", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw for the postgres backend, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: true, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when analytics is disabled, however incomplete the GCP fields are", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + analytics: { + enabled: false, + backend: "bigquery", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + }, + }), + ), + ).not.toThrow(); + }); + }); + + // config.go:1846-1854 — experimental.validate(), unconditional, internally gated. + describe("experimental.*", () => { + it("rejects a present [experimental.webhooks] section with enabled omitted", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { webhooksPresent: true, pgdeltaFormatOptions: "" } }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("rejects a present [experimental.webhooks] section with enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: false, + pgdeltaFormatOptions: "", + }, + }), + ), + ).toThrow( + "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", + ); + }); + + it("does not throw when [experimental.webhooks] enabled = true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + experimental: { + webhooksPresent: true, + webhooksEnabled: true, + pgdeltaFormatOptions: "", + }, + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [experimental.webhooks] is absent entirely", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects invalid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: "{not json" } }), + ), + ).toThrow("Invalid config for experimental.pgdelta.format_options: must be valid JSON"); + }); + + it("does not throw for valid JSON in experimental.pgdelta.format_options", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ experimental: { pgdeltaFormatOptions: '{"keywordCase":"upper"}' } }), + ), + ).not.toThrow(); + }); + + it("does not throw when experimental.pgdelta.format_options is unset", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ experimental: { pgdeltaFormatOptions: "" } })), + ).not.toThrow(); + }); + }); + + // config.go:1088-1090 — auth.site_url, checked first inside `if c.Auth.Enabled`. An absent + // `auth` section on `LegacyConfigValidationInput` IS "auth disabled" from this function's + // perspective. The SUPABASE_AUTH_ENABLED/SUPABASE_AUTH_SITE_URL env-override variants stay in + // legacy-local-config-values.unit.test.ts. + describe("auth.site_url", () => { + it("rejects an explicit empty site_url when auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ siteUrl: "" }) })), + ).toThrow("Missing required field in config: auth.site_url"); + }); + + it("does not throw when site_url is set and auth is enabled", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ siteUrl: "http://localhost:3000" }) }), + ), + ).not.toThrow(); + }); + + it("does not throw an explicit empty site_url when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1099-1109 + auth.go:58-71 — auth.captcha, checked right after auth.site_url. + describe("auth.captcha", () => { + it("rejects an enabled captcha without a provider", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: undefined, secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); + + it("rejects an enabled captcha with a provider but no secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.captcha.secret"); + }); + + it("does not throw when an enabled captcha has both provider and secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: true, provider: "hcaptcha", secret: "shh" }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when captcha is disabled, however incomplete", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: undefined, secret: undefined }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enabled captcha without provider/secret when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1117-1134 — auth.passkey/auth.webauthn, right after the (caller-side) signing-keys + // read. + describe("auth.passkey / auth.webauthn", () => { + it("rejects passkey.enabled without an [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: false, rpId: undefined, rpOrigins: undefined }, + }), + }), + ), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: undefined, + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("rejects passkey.enabled with [auth.webauthn] missing rp_origins", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { webauthnPresent: true, rpId: "localhost", rpOrigins: undefined }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("does not throw when passkey.enabled has a complete [auth.webauthn] section", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + passkey: { + webauthnPresent: true, + rpId: "localhost", + rpOrigins: ["http://localhost:3000"], + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when passkey is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ auth: minimalAuthInput({ passkey: undefined }) }), + ), + ).not.toThrow(); + }); + + it("does not throw when auth carries no passkey data at all", () => { + // Distinct from the previous test only in the original (L-level) caller's derivation — + // "webauthn absent from the document" vs. "no document was threaded through at all". + // Both collapse to `passkey: undefined` at this shared, direct-call layer. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled passkey without webauthn when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1325-1344 — auth.email.smtp, gated on the raw table being present AND enabled. + describe("auth.email.smtp", () => { + it("rejects a present [auth.email.smtp] table with no fields", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { enabled: true, host: "", port: 0, user: "", pass: "", adminEmail: "" }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); + + it("rejects a present [auth.email.smtp] table missing port/user/pass/admin_email", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).toThrow("Missing required field in config: auth.email.smtp.port"); + }); + + it("does not throw when [auth.email.smtp] explicitly sets enabled = false", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: false, + host: "smtp.example.com", + port: 0, + user: "", + pass: "", + adminEmail: "", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is a complete table", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + smtp: { + enabled: true, + host: "smtp.example.com", + port: 587, + user: "user", + pass: "pass", + adminEmail: "admin@example.com", + }, + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw when [auth.email.smtp] is absent from the input", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ smtp: undefined }) })), + ).not.toThrow(); + }); + + it("does not throw when auth carries no smtp data at all", () => { + // See the equivalent passkey note above — both collapse to `smtp: undefined` here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw a present but incomplete [auth.email.smtp] table when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1136-1138, checks @ 1453-1521 — auth.hook.*, caller pre-filters to enabled-only. + describe("auth.hook.*", () => { + it("rejects an enabled hook without a uri", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "", secrets: "" }], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.uri"); + }); + + it("rejects an http(s) hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { type: "custom_access_token", uri: "https://example.test/hook", secrets: "" }, + ], + }), + }), + ), + ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); + }); + + it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: "not-a-valid-secret", + }, + ], + }), + }), + ), + ).toThrow( + 'auth.hook.custom_access_token.secrets must be formatted as "v1,whsec_"', + ); + }); + + it("does not throw for a valid http(s) hook secret", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "https://example.test/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a pg-functions hook uri with secrets set", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: `v1,whsec_${"a".repeat(32)}`, + }, + ], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.secrets is unsupported for pg-functions URI"); + }); + + it("does not throw for a pg-functions hook uri without secrets", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [ + { + type: "custom_access_token", + uri: "pg-functions://postgres/public/hook", + secrets: "", + }, + ], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects a hook uri with an unsupported scheme", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + hooks: [{ type: "custom_access_token", uri: "ftp://example.test/hook", secrets: "" }], + }), + }), + ), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); + + it("does not throw for a disabled hook, however incomplete", () => { + // The caller pre-filters to enabled-only hooks — a disabled hook is simply absent from + // `hooks`, matching an empty array here. + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput({ hooks: [] }) })), + ).not.toThrow(); + }); + + it("does not throw an enabled hook without a uri when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1139-1141, checks @ 1523-1534 — auth.mfa.*, fixed totp/phone/web_authn order. + describe("auth.mfa.*", () => { + it.each([ + ["totp", "auth.mfa.totp.enroll_enabled requires verify_enabled"], + ["phone", "auth.mfa.phone.enroll_enabled requires verify_enabled"], + ["web_authn", "auth.mfa.web_authn.enroll_enabled requires verify_enabled"], + ] as const)("rejects %s enroll_enabled without verify_enabled", (label, message) => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label, enrollEnabled: true, verifyEnabled: false }], + }), + }), + ), + ).toThrow(message); + }); + + it("does not throw when enroll_enabled and verify_enabled are both true", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + mfa: [{ label: "totp", enrollEnabled: true, verifyEnabled: true }], + }), + }), + ), + ).not.toThrow(); + }); + + it("does not throw an enroll_enabled MFA factor without verify_enabled when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1151-1153, checks @ 1635-1683 — auth.third_party.*, fixed provider order, caller + // pre-filters to enabled-only. + describe("auth.third_party.*", () => { + it("rejects firebase enabled without a project_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "firebase", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.firebase is enabled but without a project_id."); + }); + + it("rejects auth0 enabled without a tenant", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "auth0", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.auth0 is enabled but without a tenant."); + }); + + it("rejects aws_cognito enabled without a user_pool_id", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "cognito", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id."); + }); + + it("rejects aws_cognito enabled with a user_pool_id but no user_pool_region", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "cognito", requiredField: "pool-1", cognitoUserPoolRegion: undefined }, + ], + }), + }), + ), + ).toThrow( + "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", + ); + }); + + it("rejects clerk enabled without a domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "clerk", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); + }); + + it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "not-a-clerk-domain" }], + }), + }), + ), + ).toThrow("Invalid config: auth.third_party.clerk has invalid domain"); + }); + + it("does not throw for a valid clerk.example.com domain", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [{ provider: "clerk", requiredField: "clerk.example.com" }], + }), + }), + ), + ).not.toThrow(); + }); + + it("rejects workos enabled without an issuer_url", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ thirdParty: [{ provider: "workos", requiredField: "" }] }), + }), + ), + ).toThrow("Invalid config: auth.third_party.workos is enabled but without a issuer_url."); + }); + + it("rejects more than one third_party provider enabled at once", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + thirdParty: [ + { provider: "firebase", requiredField: "proj" }, + { provider: "auth0", requiredField: "tenant" }, + ], + }), + }), + ), + ).toThrow("Invalid config: Only one third_party provider allowed to be enabled at a time."); + }); + + it("does not throw when no third_party provider is enabled", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ auth: minimalAuthInput() })), + ).not.toThrow(); + }); + + it("does not throw an enabled third_party provider missing its required field when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + }); + + // config.go:1159-1163, pattern @ 1539-1544 — every [functions.*] key, unconditional, not + // gated on auth.enabled. + describe("functions.*", () => { + it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + + it("does not throw for a valid function slug", () => { + expect(() => + legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["hello-world_v2"] })), + ).not.toThrow(); + }); + + it("does not throw when no functions are configured", () => { + expect(() => legacyValidateResolvedConfig(minimalInput())).not.toThrow(); + }); + + it("rejects an invalid function slug even when auth is disabled", () => { + expect(() => legacyValidateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( + "Invalid Function name: 1bad.", + ); + }); + }); + + // config.go:1006-1027 — only the "exactly one of cert/key set" presence rule; the actual + // file reads and the disabled-skip/env-override tests stay in + // legacy-local-config-values.unit.test.ts. + describe("api.tls", () => { + it("rejects cert_path set without key_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: "cert.pem", keyPath: undefined }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.key_path"); + }); + + it("rejects key_path set without cert_path", () => { + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + api: { + enabled: true, + port: 54321, + tls: { enabled: true, certPath: undefined, keyPath: "key.pem" }, + }, + }), + ), + ).toThrow("Missing required field in config: api.tls.cert_path"); + }); + }); + + // Direct-shared-level regression/divergence coverage (Part C) — behavior that's either new + // (the D fix) or only meaningfully testable at this exact layer (the captcha enum), not a + // move from either caller's own suite. + describe("Config.Validate divergence regression coverage", () => { + it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + // D used to fall through to the generic "Invalid db.major_version: 0" message; both D + // and L now go through this exact branch, so pin it directly here too, not just via + // D's/L's own suites. + expect(() => + legacyValidateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), + ).toThrow("Missing required field in config: db.major_version"); + }); + + it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + // This scenario is only meaningful at this direct shared-validator level: it's + // unreachable through L's real ProjectConfig-typed flow — `@supabase/config`'s schema + // (packages/config/src/auth/captcha.ts, stringEnum(["hcaptcha", "turnstile"])) already + // narrows `provider` to "hcaptcha" | "turnstile" | undefined before it ever reaches + // `legacyResolveLocalConfigValues`, so an invalid provider value would fail schema + // decoding first, on a completely different code path. D's real TOML flow CAN reach + // this branch (an untyped raw string) — D's own suite + // (`legacy-db-config.toml-read.unit.test.ts`) covers that separately. This test's job is + // only to pin the shared function's own behavior directly. + expect(() => + legacyValidateResolvedConfig( + minimalInput({ + auth: minimalAuthInput({ + captcha: { enabled: false, provider: "not-a-real-provider", secret: undefined }, + }), + }), + ), + ).toThrow( + "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + ); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 3bf5f4244f..53bfb6e947 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, Stream } 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,17 +55,115 @@ 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 * 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)))); + .pipe( + Effect.catch(() => + spawner + .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) + .pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); + +function collectDockerCliText(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())); +} + +/** + * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, + * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on + * `.` and compares the parts numerically, component by component — not a + * naive string/float compare, which would misorder e.g. `"1.9"` vs `"1.10"`. + */ +function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean { + const parts = version.split(".").map((part) => Number.parseInt(part, 10)); + const minParts = minVersion.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < Math.max(parts.length, minParts.length); index++) { + const part = parts[index] ?? 0; + const minPart = minParts[index] ?? 0; + if (part !== minPart) return part > minPart; + } + return true; +} + +/** + * Docker CLI's own `volume prune --all` flag is annotated `version: "1.42"` + * (vendored `docker/cli@v28.5.2` `cli/command/volume/prune.go:53`) and + * enforced by Cobra's `Args` validator *before* `RunE` runs + * (`cmd/docker/docker.go:659-660`): against a daemon with a lower negotiated + * API version, `docker volume prune --all ...` exits nonzero without pruning + * anything at all, rather than degrading gracefully. Go avoids ever hitting + * that by gating the equivalent `all=true` filter on + * `Docker.ClientVersion() >= "1.42"` (`docker.go:126-133`); there is no + * persistent Engine API client here to ask, so this asks the `docker` CLI + * itself via `docker version`. + * + * Deliberately does not fall back to Podman like {@link containerCliExitCode} + * does: Podman's `volume prune` never has an `--all` flag to gate in the + * first place (callers already omit it from their Podman argv + * unconditionally), so this check is meaningless on that path. Resolves to + * `false` (omit `--all`, matching how a pre-1.42 daemon's own `volume prune` + * already prunes every unused volume without it) whenever `docker` can't be + * spawned, its `version` call fails, or the reported version can't be read — + * the side that can never turn into a hard failure of the prune call itself. + */ +export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }), + ); + const [exitCode, stdout] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return false; + const version = stdout.trim(); + return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + }), + ).pipe(Effect.orElseSucceed(() => false)); 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..e80dbf5ae8 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,21 @@ 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, + legacyDockerSupportsVolumePruneAllFlag, + 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; + readonly stdout?: string; + } = {}, +) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; const spawner = ChildProcessSpawner.make((command) => @@ -13,13 +25,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`, }), ); } @@ -29,7 +41,10 @@ function mockSpawner(opts: { readonly dockerMissing?: boolean; readonly exitCode return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.empty, + stdout: + opts.stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + : Stream.empty, stderr: Stream.empty, all: Stream.empty, exitCode: Deferred.await(exitDeferred), @@ -98,4 +113,108 @@ 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("legacyDockerSupportsVolumePruneAllFlag", () => { + it.live("returns true when the daemon reports an API version at or above 1.42", () => { + const mock = mockSpawner({ stdout: "1.42" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, + ]); + }), + ); + }); + + it.live("returns true for a version comfortably above 1.42", () => { + const mock = mockSpawner({ stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + }), + ); + }); + + it.live("returns false when the daemon reports an API version below 1.42", () => { + const mock = mockSpawner({ stdout: "1.41" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("compares version components numerically, not lexicographically", () => { + // A naive string compare would misorder "1.9" as greater than "1.42" — this + // guards the numeric, component-by-component comparison instead. + const mock = mockSpawner({ stdout: "1.9" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the version command exits non-zero", () => { + const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false when the reported version is empty", () => { + const mock = mockSpawner({ stdout: "" }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + }), + ); + }); + + it.live("returns false without falling back to podman when docker cannot be spawned", () => { + const mock = mockSpawner({ dockerMissing: true }); + return legacyDockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(false); + expect(mock.spawned.map((entry) => entry.command)).toEqual(["docker"]); + }), + ); + }); +}); + +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-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 71b989aaeb..639c1169ca 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,5 +1,26 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; +import { + LEGACY_PROJECT_REF_PATTERN, + type LegacyAnalyticsInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { @@ -348,23 +369,6 @@ function findDuplicateRemoteProjectId( return undefined; } -// Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:470`): exactly 20 -// lowercase ASCII letters. -const LEGACY_PROJECT_REF_PATTERN = /^[a-z]{20}$/; - -// Go's storage bucket-name pattern (`apps/cli-go/pkg/config/config.go:1382`). -// `config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` key -// during config load (`config.go:898-903`), aborting before any db command when a -// name does not match. The source string is reused verbatim in the error message via -// `.source` so it byte-matches Go's `bucketNamePattern.String()`. -const LEGACY_BUCKET_NAME_PATTERN = /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -// Go's function-slug pattern (`apps/cli-go/pkg/config/config.go:1372`). `config.Validate` -// runs `ValidateFunctionSlug` over every `[functions.*]` key during config load -// (`config.go:993-998`), rejecting the config before any db command. `.source` is reused -// in the message so it byte-matches Go's `funcSlugPattern.String()`. -const LEGACY_FUNCTION_SLUG_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; - /** * Go's `config.Validate` rejects any `[remotes.]` whose `project_id` is not a * valid project ref (`config.go:832-836`), on every config load — so a malformed or @@ -623,33 +627,6 @@ function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** Go's `json.Valid` (`encoding/json`): reports whether the string is well-formed JSON. */ -function legacyIsValidJson(value: string): boolean { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -} - -// Go's `strconv.ParseBool` accepted forms (`go-viper/mapstructure` `decodeBool` under -// viper's forced `WeaklyTypedInput`): a string decodes to bool via ParseBool, an empty -// string is `false`, and any other value is a parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parse a config bool the way Go does (`strconv.ParseBool` via mapstructure's weakly - * typed decode). Returns the bool, or `undefined` for a malformed value (which Go - * surfaces as a `failed to parse config` error). - */ -function legacyParseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; - return undefined; -} - /** * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. @@ -864,405 +841,6 @@ const legacyAssertDecryptableSecrets = ( // Go merges the template default before Validate (`templates/config.toml`), so an absent // `auth.site_url` is non-empty; only an explicit empty string fails A1 (`config.go:1037`). const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; -// Go's `hookSecretPattern` (`pkg/config/config.go:1436`). -const LEGACY_HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; -// Go's `clerkDomainPattern` (`pkg/config/config.go:1553`). -const LEGACY_CLERK_DOMAIN_PATTERN = - /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; - -/** - * Ports the FATAL validations Go runs inside `if c.Auth.Enabled { … }` during - * `config.Validate` (`apps/cli-go/pkg/config/config.go:1036-1102` + the nested - * `.validate()` methods at 1242-1632), in Go's first-failure-wins order, so a db/migration - * command aborts on an invalid auth config exactly like the Go CLI (the reviewer's case: - * `migration down --local` must stop before any destructive work). Every check below mirrors - * a Go `return errors.New(...)` site with the byte-exact message. - * - * Deliberately NOT ported: the `assertEnvLoaded` WARN lines (Go's `config.go:1143-1148` only - * prints a stderr warning and always returns nil — never fatal), and the non-fatal mutations - * (the SMS "no provider → disable phone login" WARN, the linkedin/slack deprecation WARN); - * those affect neither the exit code nor any value this subset reader exposes. The - * linkedin/slack providers are still skipped (Go deletes them before validating). - */ -const legacyValidateAuthConfig = Effect.fnUntraced(function* ( - authRaw: RawDoc, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - lookup: EnvLookup, -) { - const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); - const supabaseDir = path.join(workdir, "supabase"); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` - // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; - }; - // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. - // A malformed string ABORTS the load like Go's decode (it does NOT coerce to false), using - // the reader's `failed to parse config: invalid .` shape (the same simplification the - // db.* bools use; Go's verbose mapstructure string is not reproduced byte-for-byte there - // either). Absent / non-string → false (the default for every auth enable-flag). - const gate = (rec: RawDoc | undefined, key: string, field: string) => - Effect.gen(function* () { - const value = rec?.[key]; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return false; - const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); - // Resolve a config file path: absolute → verbatim (Go opens it from the OS root after chdir); - // relative → joined under `base` (`filepath.IsAbs` guards, `config.go:854-878`). - const resolvePath = (p: string, base: string): string => - path.isAbsolute(p) ? p : path.join(base, p); - - // A1: site_url required (`config.go:1037-1039`). - const siteUrl = - authRaw["site_url"] === undefined ? DEFAULT_AUTH_SITE_URL : str(authRaw, "site_url"); - if (siteUrl.length === 0) return yield* fail("Missing required field in config: auth.site_url"); - - // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider.UnmarshalText`, - // `auth.go:58-71`) that fires whenever `provider` is set, regardless of `enabled`; the - // required-field checks run only when enabled (`config.go:1048-1058`). - const captcha = asRecord(authRaw["captcha"]); - if (captcha !== undefined) { - const provider = str(captcha, "provider"); - if (provider.length > 0 && provider !== "hcaptcha" && provider !== "turnstile") - return yield* fail( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", - ); - if (yield* gate(captcha, "enabled", "auth.captcha.enabled")) { - if (provider.length === 0) - return yield* fail("Missing required field in config: auth.captcha.provider"); - if (str(captcha, "secret").length === 0) - return yield* fail("Missing required field in config: auth.captcha.secret"); - } - } - - // A5: signing keys file load (`config.go:1059-1065`) — read + parse as a JSON array. A - // relative path resolves under the supabase dir (`config.go:877-878`); absolute is verbatim. - const signingKeysPath = str(authRaw, "signing_keys_path"); - if (signingKeysPath.length > 0) { - const keysJson = yield* fs.readFileString(resolvePath(signingKeysPath, supabaseDir)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `failed to read signing keys: ${cause.message}`, - }), - ), - ); - yield* Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(keysJson); - if (!Array.isArray(parsed)) throw new Error("signing keys must be a JSON array of JWKs"); - return parsed; - }, - catch: (cause) => - new LegacyDbConfigLoadError({ - message: `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - } - - // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). - const passkey = asRecord(authRaw["passkey"]); - if (passkey !== undefined && (yield* gate(passkey, "enabled", "auth.passkey.enabled"))) { - const webauthn = asRecord(authRaw["webauthn"]); - if (webauthn === undefined) - return yield* fail( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - if (str(webauthn, "rp_id").length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_id"); - const rpOrigins = webauthn["rp_origins"]; - if (!Array.isArray(rpOrigins) || rpOrigins.length === 0) - return yield* fail("Missing required field in config: auth.webauthn.rp_origins"); - } - - // B1: hooks — each enabled hook (`config.go:1402-1470`). - const hook = asRecord(authRaw["hook"]); - if (hook !== undefined) { - const hookTypes = [ - "mfa_verification_attempt", - "password_verification_attempt", - "custom_access_token", - "send_sms", - "send_email", - "before_user_created", - ] as const; - for (const hookType of hookTypes) { - const h = asRecord(hook[hookType]); - if (h === undefined) continue; - if (!(yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) continue; - const uri = str(h, "uri"); - if (uri.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.uri`); - // Go uses net/url.Parse, which (unlike `new URL`) does not throw on a missing scheme; - // extract the scheme the same lenient way so a no-scheme/other URI hits the default - // branch rather than erroring (the rare url.Parse error case is not separately ported). - const scheme = (/^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(uri)?.[1] ?? "").toLowerCase(); - const secrets = str(h, "secrets"); - if (scheme === "http" || scheme === "https") { - if (secrets.length === 0) - return yield* fail(`Missing required field in config: auth.hook.${hookType}.secrets`); - for (const secret of secrets.split("|")) { - if (!LEGACY_HOOK_SECRET_PATTERN.test(secret)) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets must be formatted as "v1,whsec_" with a minimum length of 32 characters.`, - ); - } - } else if (scheme === "pg-functions") { - if (secrets.length > 0) - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.secrets is unsupported for pg-functions URI`, - ); - } else { - return yield* fail( - `Invalid hook config: auth.hook.${hookType}.uri should be a HTTP, HTTPS, or pg-functions URI`, - ); - } - } - } - - // B2: mfa — enroll requires verify (`config.go:1472-1483`). - const mfa = asRecord(authRaw["mfa"]); - if (mfa !== undefined) { - for (const [key, label] of [ - ["totp", "totp"], - ["phone", "phone"], - ["web_authn", "web_authn"], - ] as const) { - const factor = asRecord(mfa[key]); - if (factor === undefined) continue; - const enroll = yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`); - const verify = yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`); - if (enroll && !verify) - return yield* fail( - `Invalid MFA config: auth.mfa.${label}.enroll_enabled requires verify_enabled`, - ); - } - } - - // B3: email (`config.go:1242-1295`). - const email = asRecord(authRaw["email"]); - if (email !== undefined) { - // Go resolves a relative `content_path` differently per section: email TEMPLATE paths are - // relative to the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), while - // NOTIFICATION paths are relative to the supabase dir (`config.go:861-862`); absolute → as-is. - const validateTemplate = ( - section: "template" | "notification", - name: string, - tmpl: RawDoc, - base: string, - ) => - Effect.gen(function* () { - const contentPath = str(tmpl, "content_path"); - if (contentPath.length === 0) { - if (tmpl["content"] !== undefined) - return yield* fail( - `Invalid config for auth.email.${section}.${name}.content: please use content_path instead`, - ); - return; - } - yield* fs.readFileString(resolvePath(contentPath, base)).pipe( - Effect.mapError( - (cause) => - new LegacyDbConfigLoadError({ - message: `Invalid config for auth.email.${section}.${name}.content_path: ${cause.message}`, - }), - ), - ); - }); - const templates = asRecord(email["template"]); - if (templates !== undefined) { - for (const name of Object.keys(templates)) { - const tmpl = asRecord(templates[name]); - if (tmpl !== undefined) yield* validateTemplate("template", name, tmpl, workdir); - } - } - const notifications = asRecord(email["notification"]); - if (notifications !== undefined) { - for (const name of Object.keys(notifications)) { - const tmpl = asRecord(notifications[name]); - if ( - tmpl !== undefined && - (yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) - ) - yield* validateTemplate("notification", name, tmpl, supabaseDir); - } - } - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present - // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly - // disabled. - const smtp = asRecord(email["smtp"]); - const smtpEnabled = - smtp !== undefined && - (smtp["enabled"] === undefined - ? true - : yield* gate(smtp, "enabled", "auth.email.smtp.enabled")); - if (smtp !== undefined && smtpEnabled) { - if (str(smtp, "host").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.host"); - const portRaw = smtp["port"]; - const port = - typeof portRaw === "number" - ? portRaw - : typeof portRaw === "string" - ? Number(legacyExpandEnv(portRaw, lookup)) - : 0; - if (!port) return yield* fail("Missing required field in config: auth.email.smtp.port"); - if (str(smtp, "user").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.user"); - if (str(smtp, "pass").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.pass"); - if (str(smtp, "admin_email").length === 0) - return yield* fail("Missing required field in config: auth.email.smtp.admin_email"); - } - } - - // B4: sms — only the FIRST enabled provider is validated (Go's switch, `config.go:1297-1364`). - const sms = asRecord(authRaw["sms"]); - if (sms !== undefined) { - const twilio = asRecord(sms["twilio"]); - const twilioVerify = asRecord(sms["twilio_verify"]); - const messagebird = asRecord(sms["messagebird"]); - const textlocal = asRecord(sms["textlocal"]); - const vonage = asRecord(sms["vonage"]); - // Resolve every provider's enable-flag (a malformed bool aborts like Go's decode); Go's - // switch then validates only the FIRST enabled provider. - const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); - const twilioVerifyEnabled = yield* gate( - twilioVerify, - "enabled", - "auth.sms.twilio_verify.enabled", - ); - const messagebirdEnabled = yield* gate(messagebird, "enabled", "auth.sms.messagebird.enabled"); - const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); - const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); - if (twilioEnabled) { - if (str(twilio, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); - if (str(twilio, "message_service_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.message_service_sid"); - if (str(twilio, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); - } else if (twilioVerifyEnabled) { - if (str(twilioVerify, "account_sid").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.account_sid"); - if (str(twilioVerify, "message_service_sid").length === 0) - return yield* fail( - "Missing required field in config: auth.sms.twilio_verify.message_service_sid", - ); - if (str(twilioVerify, "auth_token").length === 0) - return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); - } else if (messagebirdEnabled) { - if (str(messagebird, "originator").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); - if (str(messagebird, "access_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); - } else if (textlocalEnabled) { - if (str(textlocal, "sender").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); - if (str(textlocal, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); - } else if (vonageEnabled) { - if (str(vonage, "from").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.from"); - if (str(vonage, "api_key").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); - if (str(vonage, "api_secret").length === 0) - return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); - } - } - - // B5: external providers (`config.go:1368-1398`). linkedin/slack are deprecated and deleted - // before validation, so they are never validated here. - const external = asRecord(authRaw["external"]); - if (external !== undefined) { - for (const name of Object.keys(external)) { - if (name === "linkedin" || name === "slack") continue; - const provider = asRecord(external[name]); - if (provider === undefined) continue; - if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; - if (str(provider, "client_id").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); - if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) - return yield* fail(`Missing required field in config: auth.external.${name}.secret`); - } - } - - // B6: third_party — validate each enabled provider in order, then mutual exclusivity - // (`config.go:1584-1632`). Note `aws_cognito`'s messages say `cognito` (Go's wording). - const thirdParty = asRecord(authRaw["third_party"]); - if (thirdParty !== undefined) { - let enabledCount = 0; - const firebase = asRecord(thirdParty["firebase"]); - if ( - firebase !== undefined && - (yield* gate(firebase, "enabled", "auth.third_party.firebase.enabled")) - ) { - enabledCount += 1; - if (str(firebase, "project_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - } - const auth0 = asRecord(thirdParty["auth0"]); - if (auth0 !== undefined && (yield* gate(auth0, "enabled", "auth.third_party.auth0.enabled"))) { - enabledCount += 1; - if (str(auth0, "tenant").length === 0) - return yield* fail( - "Invalid config: auth.third_party.auth0 is enabled but without a tenant.", - ); - } - const cognito = asRecord(thirdParty["aws_cognito"]); - if ( - cognito !== undefined && - (yield* gate(cognito, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { - enabledCount += 1; - if (str(cognito, "user_pool_id").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.", - ); - if (str(cognito, "user_pool_region").length === 0) - return yield* fail( - "Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.", - ); - } - const clerk = asRecord(thirdParty["clerk"]); - if (clerk !== undefined && (yield* gate(clerk, "enabled", "auth.third_party.clerk.enabled"))) { - enabledCount += 1; - const domain = str(clerk, "domain"); - if (domain.length === 0) - return yield* fail( - "Invalid config: auth.third_party.clerk is enabled but without a domain.", - ); - if (!LEGACY_CLERK_DOMAIN_PATTERN.test(domain)) - return yield* fail( - "Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.", - ); - } - const workos = asRecord(thirdParty["workos"]); - if ( - workos !== undefined && - (yield* gate(workos, "enabled", "auth.third_party.workos.enabled")) - ) { - enabledCount += 1; - if (str(workos, "issuer_url").length === 0) - return yield* fail( - "Invalid config: auth.third_party.workos is enabled but without a issuer_url.", - ); - } - if (enabledCount > 1) - return yield* fail( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - } -}); /** * Reads `/supabase/config.toml` (db subtree + project id) and the linked @@ -1534,22 +1112,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Reject unsupported major versions like Go's config.Validate ({13,14,15,17}; - // `apps/cli-go/pkg/config/config.go:869-897`) before any image/container runs. An - // absent value falls through to the default (Go's zero-then-default). - if ( - typeof majorVersionResolved === "number" && - ![13, 14, 15, 17].includes(majorVersionResolved) - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - majorVersionResolved === 12 - ? "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects." - : `Failed reading config: Invalid db.major_version: ${majorVersionResolved}.`, - }), - ); - } + // Rejecting an unsupported major version ({13,14,15,17}; `apps/cli-go/pkg/config/config.go: + // 869-897`) is now `legacyValidateResolvedConfig`'s `db.major_version` switch (called once, + // below) — an absent value falls through to the default (Go's zero-then-default) and a present + // one (including `0`) flows into `input.db.majorVersion` for that switch to check. const majorVersion = typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; @@ -1602,25 +1168,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Go's config.Validate rejects a present-but-invalid deno_version before pg-delta - // runs (`config.go:999-1008`): 0 → missing-required, anything other than 1/2 → - // invalid. An absent key falls through to the default (Go merges deno_version=2). - if (typeof denoVersionResolved === "number") { - if (denoVersionResolved === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: edge_runtime.deno_version", - }), - ); - } - if (denoVersionResolved !== 1 && denoVersionResolved !== 2) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${denoVersionResolved}.`, - }), - ); - } - } + // Rejecting a present-but-invalid deno_version (`config.go:999-1008`: 0 → missing-required, + // anything other than 1/2 → invalid) is now `legacyValidateResolvedConfig`'s + // `edgeRuntimeDenoVersion` switch (called once, below). An absent key falls through to the + // default (Go merges deno_version=2). const denoVersion = typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; @@ -1704,60 +1255,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), lookup, ); - // Go's config.Validate aborts config load when a non-empty format_options is not - // valid JSON (`apps/cli-go/pkg/config/config.go:1685-1686`), before any shadow / - // catalog container runs. Fail here with Go's exact message so the user gets the - // actionable error up front rather than a later `JSON.parse` failure in the script. - if (formatOptionsExpanded.length > 0 && !legacyIsValidJson(formatOptionsExpanded)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - }), - ); - } + // Rejecting a non-empty, non-JSON `format_options` (`apps/cli-go/pkg/config/config.go: + // 1685-1686`) is now `legacyValidateResolvedConfig`'s `experimental.pgdeltaFormatOptions` + // check (called once, below). const formatOptions = nonEmptyString(formatOptionsExpanded); - // Go's config.Validate runs `ValidateBucketName` over every `[storage.buckets.*]` - // key on load (`apps/cli-go/pkg/config/config.go:898-903`), rejecting the config - // before any db command when a bucket name does not match `bucketNamePattern`. - // The reader otherwise drops `storage.buckets`, so port the check here with Go's - // exact message (the trailing `(%s)` is the regex source, `config.go:1386`). + // Bucket-name/function-slug validation (`config.go:898-903`/`993-998`) now lives in + // `legacyValidateResolvedConfig` (called once, below); only the pure extraction stays here. const bucketsRaw = asRecord(storageRaw?.["buckets"]); - if (bucketsRaw !== undefined) { - for (const name of Object.keys(bucketsRaw)) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN.source})`, - }), - ); - } - } - } - // Go's config.Validate runs `ValidateFunctionSlug` over every `[functions.*]` key on - // load (`apps/cli-go/pkg/config/config.go:993-998`, immediately after the bucket loop), - // rejecting the config before any db command when a slug does not match - // `funcSlugPattern`. The reader otherwise drops `functions`, so port the check here - // with Go's exact message (the trailing `(%s)` is the regex source, `config.go:1376`). - if (functionsRaw !== undefined) { - for (const name of Object.keys(functionsRaw)) { - if (!LEGACY_FUNCTION_SLUG_PATTERN.test(name)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid Function name: ${name}. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (${LEGACY_FUNCTION_SLUG_PATTERN.source})`, - }), - ); - } - } - } - - // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) - // after the bucket/function checks — port its fatal validations so db/migration commands - // abort on an invalid auth config exactly like Go (e.g. an enabled passkey without a valid - // [auth.webauthn], or two third_party providers). Gated on `auth.enabled` (default true). - // Go's viper AutomaticEnv binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate - // (`config.go:529-535`), so the env override decides whether the auth block is validated. + // Go's config.Validate runs the full `if c.Auth.Enabled` block (`config.go:1036-1102`) after + // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv + // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate (`config.go:529-535`), so the + // env override decides whether the auth block is validated. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], @@ -1765,41 +1275,300 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, envOverride("SUPABASE_AUTH_ENABLED"), ); + + // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity + // CHECKS now live in `legacyValidateResolvedConfig`; `str`/`gate`/`fail` are still needed here + // to build that call's `LegacyAuthInput`, and by the D-only sms/external checks below (never + // part of the shared validator — see `legacy-config-validate.ts`'s module header). + const fail = (message: string) => Effect.fail(new LegacyDbConfigLoadError({ message })); + // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` + // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. + const str = (rec: RawDoc | undefined, key: string): string => { + const value = rec?.[key]; + return typeof value === "string" ? legacyExpandEnv(value, lookup) : ""; + }; + // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. A + // malformed string ABORTS the load like Go's decode (it does NOT coerce to false). Absent / + // non-string → false (the default for every auth enable-flag). + const gate = (rec: RawDoc | undefined, key: string, field: string) => + Effect.gen(function* () { + const value = rec?.[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return false; + const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); + if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); + return parsed; + }); + + const authRawResolved = authRaw ?? {}; + let authInput: LegacyAuthInput | undefined; if (authEnabled) { - yield* legacyValidateAuthConfig(authRaw ?? {}, fs, path, workdir, lookup); + // A1: site_url required (`config.go:1037-1039`). + const siteUrl = + authRawResolved["site_url"] === undefined + ? DEFAULT_AUTH_SITE_URL + : str(authRawResolved, "site_url"); + + // A4: [auth.captcha]. The provider enum is a decode-time check (`CaptchaProvider. + // UnmarshalText`, `auth.go:58-71`); the required-field checks are `enabled`-gated + // (`config.go:1048-1058`) — both now live in `legacyValidateResolvedConfig`. + const captchaRaw = asRecord(authRawResolved["captcha"]); + let captchaInput: LegacyCaptchaInput | undefined; + if (captchaRaw !== undefined) { + const provider = str(captchaRaw, "provider"); + const secret = str(captchaRaw, "secret"); + captchaInput = { + enabled: yield* gate(captchaRaw, "enabled", "auth.captcha.enabled"), + // `str()` returns `""` for an absent key, but the shared validator's + // `provider === undefined` check needs a real `undefined` to fire correctly for an + // enabled captcha with no provider set. + provider: provider.length > 0 ? provider : undefined, + secret: secret.length > 0 ? secret : undefined, + }; + } + + // A5: signing keys file load (`config.go:1059-1065`) — I/O, stays in D. A relative path + // resolves under the supabase dir; absolute is verbatim. + const signingKeysPath = str(authRawResolved, "signing_keys_path"); + if (signingKeysPath.length > 0) { + const keysJson = yield* fs + .readFileString(legacyResolveSigningKeysPath(workdir, signingKeysPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysReadErrorMessage(cause) }), + ), + ); + yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(keysJson); + if (!Array.isArray(parsed)) { + throw new Error("signing keys must be a JSON array of JWKs"); + } + return parsed; + }, + catch: (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), + }); + } + + // A6: passkey/webauthn when passkey enabled (`config.go:1066-1084`). + const passkeyRaw = asRecord(authRawResolved["passkey"]); + let passkeyInput: LegacyPasskeyInput | undefined; + if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { + const webauthnRaw = asRecord(authRawResolved["webauthn"]); + const rpOrigins = webauthnRaw?.["rp_origins"]; + passkeyInput = { + webauthnPresent: webauthnRaw !== undefined, + rpId: str(webauthnRaw, "rp_id"), + rpOrigins: Array.isArray(rpOrigins) ? rpOrigins : undefined, + }; + } + + // B1: hooks — each enabled hook, Go's fixed iteration order (`config.go:1402-1470`). + const hookRaw = asRecord(authRawResolved["hook"]); + const hookTypes = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", + ] as const; + const hooks: Array = []; + for (const hookType of hookTypes) { + const h = asRecord(hookRaw?.[hookType]); + if (h !== undefined && (yield* gate(h, "enabled", `auth.hook.${hookType}.enabled`))) { + hooks.push({ type: hookType, uri: str(h, "uri"), secrets: str(h, "secrets") }); + } + } + + // B2: mfa — enroll requires verify (`config.go:1472-1483`), fixed totp/phone/web_authn order. + const mfaRaw = asRecord(authRawResolved["mfa"]); + const mfa: Array = []; + for (const label of ["totp", "phone", "web_authn"] as const) { + const factor = asRecord(mfaRaw?.[label]); + mfa.push({ + label, + enrollEnabled: yield* gate(factor, "enroll_enabled", `auth.mfa.${label}.enroll_enabled`), + verifyEnabled: yield* gate(factor, "verify_enabled", `auth.mfa.${label}.verify_enabled`), + }); + } + + // B3: email (`config.go:1242-1295`) — template/notification content is I/O, stays in D. Go + // resolves a relative `content_path` differently per section: TEMPLATE paths are relative to + // the PROJECT ROOT (`config.go:854-856`, the `// FIXME` there), NOTIFICATION paths are + // relative to the supabase dir (`config.go:861-862`); absolute → as-is. + const emailRaw = asRecord(authRawResolved["email"]); + const templatesRaw = asRecord(emailRaw?.["template"]); + if (templatesRaw !== undefined) { + for (const name of Object.keys(templatesRaw)) { + const tmpl = asRecord(templatesRaw[name]); + if (tmpl === undefined) continue; + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("template", name, cause), + }), + ), + ); + } + } + const notificationsRaw = asRecord(emailRaw?.["notification"]); + if (notificationsRaw !== undefined) { + for (const name of Object.keys(notificationsRaw)) { + const tmpl = asRecord(notificationsRaw[name]); + if ( + tmpl === undefined || + !(yield* gate(tmpl, "enabled", `auth.email.notification.${name}.enabled`)) + ) { + continue; + } + const contentPath = yield* Effect.try({ + try: () => + legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: supabaseDir, + }), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: legacyEmailContentPathReadErrorMessage("notification", name, cause), + }), + ), + ); + } + } + // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present + // but omits `enabled` (`config.go:692-696`), so a present table validates unless explicitly + // disabled. + const smtpRaw = asRecord(emailRaw?.["smtp"]); + let smtpInput: LegacySmtpInput | undefined; + if (smtpRaw !== undefined) { + const smtpPortRaw = smtpRaw["port"]; + // The shared validator's required-field check is `port === 0` (Go decodes `port` into a + // numeric type at the config-decode step, so it can never observe a non-numeric value here). + // D reads the raw TOML/env string directly, so a non-numeric `port` (or an unresolved + // `env(VAR)`) parses to `NaN` via `Number(...)` — normalize that to `0` so it still trips + // the "missing required field" check instead of silently passing config load. + const smtpPortNumeric = + typeof smtpPortRaw === "number" + ? smtpPortRaw + : typeof smtpPortRaw === "string" + ? Number(legacyExpandEnv(smtpPortRaw, lookup)) + : 0; + smtpInput = { + enabled: + smtpRaw["enabled"] === undefined + ? true + : yield* gate(smtpRaw, "enabled", "auth.email.smtp.enabled"), + host: str(smtpRaw, "host"), + port: Number.isNaN(smtpPortNumeric) ? 0 : smtpPortNumeric, + user: str(smtpRaw, "user"), + pass: str(smtpRaw, "pass"), + adminEmail: str(smtpRaw, "admin_email"), + }; + } + + // B6: third_party — each enabled provider, Go's fixed order (`config.go:1584-1632`). Note + // `aws_cognito`'s messages say `cognito` (Go's wording). + const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const thirdParty: Array = []; + const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); + if ( + firebaseRaw !== undefined && + (yield* gate(firebaseRaw, "enabled", "auth.third_party.firebase.enabled")) + ) { + thirdParty.push({ provider: "firebase", requiredField: str(firebaseRaw, "project_id") }); + } + const auth0Raw = asRecord(thirdPartyRaw?.["auth0"]); + if ( + auth0Raw !== undefined && + (yield* gate(auth0Raw, "enabled", "auth.third_party.auth0.enabled")) + ) { + thirdParty.push({ provider: "auth0", requiredField: str(auth0Raw, "tenant") }); + } + const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); + if ( + cognitoRaw !== undefined && + (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) + ) { + thirdParty.push({ + provider: "cognito", + requiredField: str(cognitoRaw, "user_pool_id"), + cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + }); + } + const clerkRaw = asRecord(thirdPartyRaw?.["clerk"]); + if ( + clerkRaw !== undefined && + (yield* gate(clerkRaw, "enabled", "auth.third_party.clerk.enabled")) + ) { + thirdParty.push({ provider: "clerk", requiredField: str(clerkRaw, "domain") }); + } + const workosRaw = asRecord(thirdPartyRaw?.["workos"]); + if ( + workosRaw !== undefined && + (yield* gate(workosRaw, "enabled", "auth.third_party.workos.enabled")) + ) { + thirdParty.push({ provider: "workos", requiredField: str(workosRaw, "issuer_url") }); + } + + authInput = { + siteUrl, + captcha: captchaInput, + passkey: passkeyInput, + hooks, + mfa, + smtp: smtpInput, + thirdParty, + }; } // Go's config.Validate validates `[analytics]` after the auth block (`config.go:1123-1135`). - // Two fatal checks run on the db/migration path: - // 1. `LogflareBackend.UnmarshalText` (`config.go:60-66`) is a decode-time enum that rejects - // any `backend` other than `postgres`/`bigquery` — regardless of `enabled` (it fires - // during UnmarshalExact, like the captcha provider enum), so it gates here too. - // 2. When analytics is enabled with the BigQuery backend, the three GCP fields are required - // (`config.go:1124-1134`), in order, with byte-exact messages. - // Go merges the template defaults `enabled = true`, `backend = "postgres"` before Validate - // (`templates/config.toml:388-392`), so an absent `[analytics]` section is enabled+postgres and - // passes (an empty backend never equals `bigquery`, so the GCP block is skipped). viper - // AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a matched remote block makes those keys env-immune, - // same as every other `LEGACY_ENV_OVERRIDABLE_KEYS` field above. + // Computed here (after the auth block, before the single shared call) rather than pure-derived + // earlier: `analyticsEnabled` resolves through `resolveBoolOrFail`, which can itself FAIL on a + // malformed `SUPABASE_ANALYTICS_ENABLED`/`analytics.enabled` bool — positioning that failure + // point ahead of the auth block would report it before an auth-block error even for a config + // that's ALSO broken there, reversing Go's real order. Go merges the template defaults + // `enabled = true`, `backend = "postgres"` before Validate (`templates/config.toml:388-392`), so + // an absent `[analytics]` section is enabled+postgres and passes (an empty backend never equals + // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a + // matched remote block makes those keys env-immune, same as every other + // `LEGACY_ENV_OVERRIDABLE_KEYS` field above. const analyticsString = (key: string, envName: string): string => { const fromEnv = remoteOverrideKeys.has(`analytics.${key}`) ? undefined : envOverride(envName); const raw = fromEnv ?? analyticsRaw?.[key]; return typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; }; const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - if ( - analyticsBackend.length > 0 && - analyticsBackend !== "postgres" && - analyticsBackend !== "bigquery" - ) { - // Mirror the captcha enum's mapstructure envelope (`%v` of the allowed `[]LogflareBackend`). - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", - }), - ); - } const analyticsEnabled = yield* resolveBoolOrFail( "analytics.enabled", analyticsRaw?.["enabled"], @@ -1809,32 +1578,128 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); - if (analyticsEnabled && analyticsBackend === "bigquery") { - // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays - // non-empty and passes the `len(...) == 0` check, exactly like Go. - if (analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_id", - }), + // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays + // non-empty and passes the shared validator's `length === 0` check, exactly like Go. + const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); + const gcpProjectNumber = analyticsString( + "gcp_project_number", + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + ); + const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own (db.port + // is checked earlier, above, and stays there — see the comment at that check) is deferred to + // this single call, in Go's exact relative order. D's sms/external checks (D-only, never part + // of the shared validator) run AFTER this call succeeds, still gated on `authEnabled` — see + // `legacy-config-validate.ts`'s module header for the accepted ordering tradeoff this + // introduces against third_party. + const dbInput: LegacyDbInput = { port, majorVersion }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, + gcpProjectId, + gcpProjectNumber, + gcpJwtPath, + }; + const experimentalInput: LegacyExperimentalInput = { + pgdeltaFormatOptions: formatOptionsExpanded, + }; + const validationInput: LegacyConfigValidationInput = { + db: dbInput, + storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], + functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + auth: authInput, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + yield* Effect.try({ + try: () => legacyValidateResolvedConfig(validationInput), + catch: (cause) => + new LegacyDbConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + if (authEnabled) { + // B4: sms — D-only (`config.go:1145-1147`/`1348-1417`, never part of the shared validator, + // see `legacy-config-validate.ts`'s module header). Only the FIRST enabled provider is + // validated (Go's switch). + const sms = asRecord(authRawResolved["sms"]); + if (sms !== undefined) { + const twilio = asRecord(sms["twilio"]); + const twilioVerify = asRecord(sms["twilio_verify"]); + const messagebird = asRecord(sms["messagebird"]); + const textlocal = asRecord(sms["textlocal"]); + const vonage = asRecord(sms["vonage"]); + const twilioEnabled = yield* gate(twilio, "enabled", "auth.sms.twilio.enabled"); + const twilioVerifyEnabled = yield* gate( + twilioVerify, + "enabled", + "auth.sms.twilio_verify.enabled", ); - } - if ( - analyticsString("gcp_project_number", "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER").length === 0 - ) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "Missing required field in config: analytics.gcp_project_number", - }), + const messagebirdEnabled = yield* gate( + messagebird, + "enabled", + "auth.sms.messagebird.enabled", ); + const textlocalEnabled = yield* gate(textlocal, "enabled", "auth.sms.textlocal.enabled"); + const vonageEnabled = yield* gate(vonage, "enabled", "auth.sms.vonage.enabled"); + if (twilioEnabled) { + if (str(twilio, "account_sid").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.account_sid"); + if (str(twilio, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio.message_service_sid", + ); + if (str(twilio, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio.auth_token"); + } else if (twilioVerifyEnabled) { + if (str(twilioVerify, "account_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.account_sid", + ); + if (str(twilioVerify, "message_service_sid").length === 0) + return yield* fail( + "Missing required field in config: auth.sms.twilio_verify.message_service_sid", + ); + if (str(twilioVerify, "auth_token").length === 0) + return yield* fail("Missing required field in config: auth.sms.twilio_verify.auth_token"); + } else if (messagebirdEnabled) { + if (str(messagebird, "originator").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.originator"); + if (str(messagebird, "access_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.messagebird.access_key"); + } else if (textlocalEnabled) { + if (str(textlocal, "sender").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.sender"); + if (str(textlocal, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.textlocal.api_key"); + } else if (vonageEnabled) { + if (str(vonage, "from").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.from"); + if (str(vonage, "api_key").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_key"); + if (str(vonage, "api_secret").length === 0) + return yield* fail("Missing required field in config: auth.sms.vonage.api_secret"); + } } - if (analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH").length === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: - "Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path", - }), - ); + + // B5: external providers — D-only (`config.go:1148-1150`/`1368-1398`, never part of the + // shared validator). linkedin/slack are deprecated and deleted before validation, so they are + // never validated here. + const external = asRecord(authRawResolved["external"]); + if (external !== undefined) { + for (const name of Object.keys(external)) { + if (name === "linkedin" || name === "slack") continue; + const provider = asRecord(external[name]); + if (provider === undefined) continue; + if (!(yield* gate(provider, "enabled", `auth.external.${name}.enabled`))) continue; + if (str(provider, "client_id").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.client_id`); + if (name !== "apple" && name !== "google" && str(provider, "secret").length === 0) + return yield* fail(`Missing required field in config: auth.external.${name}.secret`); + } } } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 3fd179eaf8..673fd52d41 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1530,6 +1530,28 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + // Divergence #1 fix: this used to fall through to the generic + // "Failed reading config: Invalid db.major_version: 0." message (the same branch that + // catches an unsupported value like 16) — `legacyValidateResolvedConfig`'s dedicated `0` case + // now matches Go's `Missing required field in config: db.major_version`. + const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: db.major_version", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { const dir = withConfig(["[db]", "major_version = 12", ""].join("\n")); return read(dir).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 41a5e74b14..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. */ @@ -52,3 +67,57 @@ 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. + * + * This function itself does not sanitize — by design, it's a pure pass-through. + * The caller is responsible for sanitizing `projectId` with + * {@link legacySanitizeProjectId} on the config/env-derived (default) path + * BEFORE calling this, matching Go's `Config.Validate` sanitizing the + * `Config.ProjectId` singleton once at config-load time so every later + * reader — including the Docker LABEL `start` writes — sees the same + * sanitized string. An explicit `--project-id ` (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; + 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..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 @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { legacyResolveLocalProjectId, localDbContainerId } from "./legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + legacyCliProjectFilterValue, + legacyResolveLocalProjectId, + legacySanitizeProjectId, + legacyServiceContainerIds, + localDbContainerId, +} from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -23,3 +30,72 @@ 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`); + }); + + 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"); + }); +}); 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..f8884a700a --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -0,0 +1,259 @@ +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); +} + +/** + * 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)}`, + }), + ), + ); + // 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" }), + ), + ); + 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}}`. 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( + 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)}`, + }), + ), + ); + // 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({ + message: "failed to inspect container health", + }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + 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 : {}; + // Go's `assertContainerHealthy` (`internal/status/status.go:147-156`) gates + // on the boolean `resp.State.Running`, not the status string — Docker's + // inspect `State` struct exposes both independently, and a paused or + // restarting container reports `Running: true` alongside a non-"running" + // `Status` (`"paused"`/`"restarting"`). `status` is kept as-is for the + // "container is not running: " message text (`status.go:151`), + // which still reads the string, but the gate itself must read the boolean. + const status = typeof state["Status"] === "string" ? state["Status"] : ""; + const running = state["Running"] === true; + 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)}`, + }), + ), + ); + // 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" }), + ), + ); + 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..0ecc41b372 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -0,0 +1,344 @@ +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", + Running: true, + 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", Running: true }) }); + 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", Running: false }) }); + return legacyInspectContainerState(mock.spawner, "supabase_kong_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: false, status: "exited" }); + }), + ); + }); + + it.live( + "treats a paused/restarting container as running, matching Go's boolean-based gate", + () => { + // Go's `assertContainerHealthy` (`status.go:150`) checks `resp.State.Running`, + // not `resp.State.Status` — a paused or restarting container reports + // `Running: true` alongside a non-"running" status string, and Go + // continues past the not-running branch in that case. + const mock = mockSpawner({ stdout: JSON.stringify({ Status: "paused", Running: true }) }); + return legacyInspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused" }); + }), + ); + }, + ); + + 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" }); + 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..a7b2768edf --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -0,0 +1,197 @@ +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"`, + * 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}`; +} + +/** 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; + +function base64UrlToBigInt(value: string): bigint { + const hex = Buffer.from(value, "base64url").toString("hex"); + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBase64Url(value: bigint): string { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + return Buffer.from(hex, "hex").toString("base64url"); +} + +/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`a`/`m` coprime, as `q`/`p` always are for a valid RSA key). */ +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const quotient = oldR / r; + [oldR, r] = [r, oldR - quotient * r]; + [oldS, s] = [s, oldS - quotient * s]; + } + return ((oldS % m) + m) % m; +} + +/** + * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) Go's `jwkToRSAPrivateKey` + * (`apps/cli-go/pkg/config/apikeys.go:132-168`) never reads — it constructs + * `rsa.PrivateKey{N, E, D, Primes: [p, q]}` from `n`/`e`/`d`/`p`/`q` alone, and + * Go's stdlib `crypto/rsa` (`SignPKCS1v15` -> `precompute()`) lazily derives + * `Dp`/`Dq`/`Qinv` from `p`/`q`/`d` itself when they're absent, so a JWK + * missing them still signs successfully in Go. Node's + * `createPrivateKey({ format: "jwk" })` has no such fallback — it hard-rejects + * an RSA JWK without `dp`/`dq`/`qi` (`The "key.dp" property must be of type + * string`) — so this reproduces Go's derivation before handing the key to + * Node: `dp = d mod (p-1)`, `dq = d mod (q-1)`, `qi = q^-1 mod p` (RFC 7517 + * section 6.3.2 / RFC 3447 section 3.2). A key that already has all three (the common case + * for a Node/openssl-generated JWK) is returned unchanged; one missing + * `d`/`p`/`q` themselves is also returned unchanged — that's a genuinely + * invalid key in Go too, and `createPrivateKey` will raise its own error. + */ +function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { + if (jwk.dp !== undefined && jwk.dq !== undefined && jwk.qi !== undefined) { + return jwk; + } + if (jwk.d === undefined || jwk.p === undefined || jwk.q === undefined) { + return jwk; + } + const d = base64UrlToBigInt(jwk.d); + const p = base64UrlToBigInt(jwk.p); + const q = base64UrlToBigInt(jwk.q); + return { + ...jwk, + dp: bigIntToBase64Url(d % (p - 1n)), + dq: bigIntToBase64Url(d % (q - 1n)), + qi: bigIntToBase64Url(modInverse(q, p)), + }; +} + +/** + * 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`. `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. + * + * `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 ?? ""}`); + } + 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" } + : { 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: algorithm === "RS256" ? ensureRsaCrtParams(jwk) : 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 new file mode 100644 index 0000000000..5bfeabfe70 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -0,0 +1,179 @@ +import { createHmac, generateKeyPairSync } from "node:crypto"; +import { importJWK, jwtVerify } from "jose"; +import { describe, expect, it } from "vitest"; + +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"); +} + +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); + }); +}); + +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 an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { + // Go's `jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-168`) + // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` + // alone, and Go's stdlib derives the CRT params itself when absent. A + // hand-authored signing-keys file that omits them (common — RFC 7517 marks + // them optional) must still sign successfully here. + const jwk = generateRsaJwk("rsa-kid"); + const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; + const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "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: ", + ); + }); + + 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: ", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 087363c20b..d2c6d719cd 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -1,17 +1,128 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + const LOCAL_HOST = "127.0.0.1"; +/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +const DEFAULT_CONTEXT_NAME = "default"; + +/** + * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` + * (`docker/cli` `cliconfig.Dir()`), read from here rather than + * `client.Client`'s own resolution since this module never spawns a real + * Docker client — it only needs the same two on-disk files that resolution + * reads. + */ +function dockerConfigDir(): string { + const override = process.env["DOCKER_CONFIG"]; + return override !== undefined && override.length > 0 ? override : join(homedir(), ".docker"); +} + +/** + * Go's `cli.CurrentContext()` name resolution (`docker/cli` + * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the + * config file's `currentContext`, else `"default"`. Only reached when + * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` + * when `DOCKER_HOST`/`--host` is set, which {@link legacyGetHostname} below + * already handles as its own, earlier branch. + */ +function currentDockerContextName(): string { + const fromEnv = process.env["DOCKER_CONTEXT"]; + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + try { + const config = JSON.parse(readFileSync(join(dockerConfigDir(), "config.json"), "utf8")) as { + currentContext?: unknown; + }; + if (typeof config.currentContext === "string" && config.currentContext.length > 0) { + return config.currentContext; + } + } catch { + // Missing/malformed config.json → the default context, same as Go's own + // silent fallback when it can't load the config file here. + } + return DEFAULT_CONTEXT_NAME; +} + +/** + * Reads a non-default context's daemon endpoint from Docker CLI's context + * store: `/contexts/meta//meta.json`'s + * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). + * The `"default"` context has no store entry (it's Go's synthetic + * always-available context, resolved without a client, see + * `cli.Initialize`), so it's never looked up here — matching the earlier + * `"default"` short-circuit in {@link currentDockerContextName}'s caller. + */ +function dockerContextEndpointHost(contextName: string): string | undefined { + if (contextName === DEFAULT_CONTEXT_NAME) { + return undefined; + } + try { + const contextId = createHash("sha256").update(contextName).digest("hex"); + const metaPath = join(dockerConfigDir(), "contexts", "meta", contextId, "meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { + readonly Endpoints?: { readonly docker?: { readonly Host?: unknown } }; + }; + const host = meta.Endpoints?.docker?.Host; + return typeof host === "string" && host.length > 0 ? host : undefined; + } catch { + // Missing/malformed context store entry → treat as unresolvable, same as + // Go silently falling back to the loopback default below. + return undefined; + } +} + +/** + * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring + * Go's `client.ParseHostURL` + `net.SplitHostPort` (`misc.go:307`). Returns + * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an + * unparseable one, in which case the caller falls back to the loopback + * default, matching Go's `net.SplitHostPort` failure/non-TCP handling. + */ +function hostFromTcpEndpoint(endpoint: string): string | undefined { + try { + const url = new URL(endpoint); + if (url.protocol !== "tcp:" || url.hostname.length === 0) { + return undefined; + } + // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's + // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a + // single surrounding bracket pair so local-stack probes dial/compare the + // same host Go does; IPv4 and named hosts are returned unchanged. + const host = url.hostname; + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + } catch { + return undefined; + } +} + /** * Resolves the hostname used for local Supabase service connections, mirroring - * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298`): + * Go's `utils.GetHostname` (`apps/cli-go/internal/utils/misc.go:298-311`): * * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when * the Docker daemon is not reachable on the container's own loopback. * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint * (Go's `Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. `127.0.0.1` otherwise (the default unix-socket daemon). + * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a + * `tcp://` one — Go's `Docker.DaemonHost()` comes from a client built via + * `command.NewDockerCli()` + `cli.Initialize()` (`apps/cli-go/internal/ + * utils/docker.go:41-54`), whose endpoint resolution walks `DOCKER_HOST` -> + * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context + * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ + * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` + * binary this module's callers shell out to for `ps`/`inspect` already + * resolves the same active context itself, so without this step `status` + * could correctly inspect a remote daemon while printing unusable + * `127.0.0.1` API/DB/Studio URLs for it. + * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an + * unresolvable/malformed context). * * Shared across legacy commands that connect to the local stack (`gen types`, - * `test db`, and later `db reset` / `db dump`). + * `test db`, `status`, `stop`, and later `db reset` / `db dump`). */ export function legacyGetHostname(): string { const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; @@ -20,18 +131,13 @@ export function legacyGetHostname(): string { } const dockerHost = process.env["DOCKER_HOST"]; if (dockerHost !== undefined && dockerHost.length > 0) { - try { - const url = new URL(dockerHost); - if (url.protocol === "tcp:" && url.hostname.length > 0) { - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` (`misc.go:307`) returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. - const host = url.hostname; - return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - } - } catch { - // Unparseable DOCKER_HOST → fall through to the loopback default. + return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; + } + const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); + if (contextEndpoint !== undefined) { + const host = hostFromTcpEndpoint(contextEndpoint); + if (host !== undefined) { + return host; } } return LOCAL_HOST; diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 9706f5f002..0f1c351d0e 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { legacyGetHostname } from "./legacy-hostname.ts"; @@ -19,6 +23,30 @@ function withEnv(entries: Record, run: () => T): } } +/** Writes a Docker CLI-shaped `$DOCKER_CONFIG` directory (`config.json` + a context store entry). */ +function writeDockerConfigDir(options: { + readonly currentContext?: string; + readonly contexts?: Readonly>; // context name -> docker.Host endpoint +}): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-hostname-docker-config-")); + if (options.currentContext !== undefined) { + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ currentContext: options.currentContext }), + ); + } + for (const [name, host] of Object.entries(options.contexts ?? {})) { + const contextId = createHash("sha256").update(name).digest("hex"); + const metaDir = join(dir, "contexts", "meta", contextId); + mkdirSync(metaDir, { recursive: true }); + writeFileSync( + join(metaDir, "meta.json"), + JSON.stringify({ Endpoints: { docker: { Host: host } } }), + ); + } + return dir; +} + describe("legacyGetHostname", () => { it("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => { expect( @@ -63,4 +91,98 @@ describe("legacyGetHostname", () => { withEnv({ SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: undefined }, legacyGetHostname), ).toBe("127.0.0.1"); }); + + describe("active Docker context resolution (Go's Docker.DaemonHost() parity)", () => { + let configDirs: Array = []; + + afterEach(() => { + for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); + configDirs = []; + }); + + function withDockerConfig( + options: Parameters[0], + env: Record, + run: () => T, + ): T { + const dir = writeDockerConfigDir(options); + configDirs.push(dir); + return withEnv( + { + SUPABASE_SERVICES_HOSTNAME: undefined, + DOCKER_HOST: undefined, + DOCKER_CONFIG: dir, + ...env, + }, + run, + ); + } + + it("resolves the host from the active context's tcp:// endpoint via config.json's currentContext", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("remote-host"); + }); + + it("prefers DOCKER_CONTEXT over config.json's currentContext", () => { + const result = withDockerConfig( + { + currentContext: "other", + contexts: { envctx: "tcp://envctx-host:2375", other: "tcp://other-host:2375" }, + }, + { DOCKER_CONTEXT: "envctx" }, + legacyGetHostname, + ); + expect(result).toBe("envctx-host"); + }); + + it("strips brackets from an IPv6 context endpoint (net.SplitHostPort parity)", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("::1"); + }); + + it("falls back to 127.0.0.1 when the active context's endpoint is not tcp://", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when the context store entry is missing", () => { + const result = withDockerConfig({ currentContext: "ghost" }, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("falls back to 127.0.0.1 when config.json is missing entirely (default context)", () => { + const result = withDockerConfig({}, {}, legacyGetHostname); + expect(result).toBe("127.0.0.1"); + }); + + it("never consults the context store for the default context", () => { + const result = withDockerConfig( + { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, + {}, + legacyGetHostname, + ); + expect(result).toBe("127.0.0.1"); + }); + + it("DOCKER_HOST still takes precedence over an active non-default context", () => { + const result = withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, + { DOCKER_HOST: "tcp://direct-host:2375" }, + legacyGetHostname, + ); + expect(result).toBe("direct-host"); + }); + }); }); 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..467227be80 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -0,0 +1,1070 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ENV_CAPTURE_REGEX, 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 { + legacyApiTlsCertReadErrorMessage, + legacyApiTlsKeyReadErrorMessage, + type LegacyAnalyticsInput, + type LegacyApiInput, + type LegacyAuthInput, + type LegacyCaptchaInput, + LegacyConfigValidateError, + type LegacyConfigValidationInput, + type LegacyDbInput, + legacyEmailContentPathReadErrorMessage, + type LegacyExperimentalInput, + type LegacyHookInput, + type LegacyLocalSmtpInput, + type LegacyMfaFactorInput, + legacyParseGoBool, + type LegacyPasskeyInput, + legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, + legacyResolveSigningKeysPath, + legacySigningKeysDecodeErrorMessage, + legacySigningKeysReadErrorMessage, + type LegacySmtpInput, + type LegacyStudioInput, + type LegacyThirdPartyInput, + legacyValidateResolvedConfig, +} from "./legacy-config-validate.ts"; +import { + legacyGenerateAsymmetricGoJwt, + legacyGenerateGoJwt, + type LegacyJwk, +} 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}`; +} + +/** + * 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; + +/** + * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port, mirroring Go's `Config.Load` + * (`pkg/config/config.go:749-756`): `v.UnmarshalExact` decodes with + * `WeaklyTypedInput` on (viper's `defaultDecoderConfig`, never reset by our + * decoder options), so mapstructure's `decodeUint` runs `strconv.ParseUint` + * on the override string and hard-fails config loading on a bad value — + * there is no Go code path that reaches `status`/`stop` with a malformed + * port override. The message text isn't a byte-match for mapstructure's + * internal error (that's viper/mapstructure library text, not a Go-authored + * string), but the parity-relevant part — hard-fail, same field name — is. + */ +export class LegacyInvalidPortEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); + this.name = "LegacyInvalidPortEnvOverrideError"; + } +} + +/** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ +const MAX_PORT = 65535; + +/** + * Port-flavored sibling of {@link envOverride}/{@link legacyEnvOverrideBool} + * for `SUPABASE_*_PORT` fields Go decodes as `uint16` rather than a plain + * string. Unlike the boolean sibling — which intentionally falls back to + * `configured` on a malformed override — a bad port override is a genuine + * Go-parity hard failure (see {@link LegacyInvalidPortEnvOverrideError}), not + * a leniency case: Go never proceeds with the pre-override value on a decode + * error, it fails config loading outright. + */ +function envOverridePort( + name: string, + configuredPort: number, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configuredPort; + if (!/^\d+$/.test(value)) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + const port = Number(value); + if (port > MAX_PORT) { + throw new LegacyInvalidPortEnvOverrideError(dottedFieldPath, value); + } + return port; +} + +/** + * 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, + * generically across the whole struct — not just auth fields + * (`config_test.go:351,1061` exercise this against `auth.site_url`, and + * `internal/status/status.go:52-95`'s `toValues()` reads `utils.Config.*` + * directly, so every already-overridden field is automatically reflected in + * `status`'s output). This resolves it for every field this module derives a + * URL/port from, 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`). + * + * 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`. + * + * The resolved override string itself can be a further `env(VAR)` indirection + * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`) — Go's `LoadEnvHook` + * (`decode_hooks.go:15-23`) is the first mapstructure decode hook composed + * into `v.UnmarshalExact` (`config.go:749-753,769-772`), so it resolves + * `env(...)` on every string mapstructure decodes into the struct, regardless + * of whether Viper sourced that string from `config.toml` or a `SUPABASE_*` + * `AutomaticEnv` override (`config.go:582-586`) — Viper's `Get()` just returns + * a string; the hook chain doesn't know or care where it came from. Resolved + * with the same `projectEnvValues ?? process.env` precedence and non-empty + * gate as the outer lookup (mirroring `decode_hooks.go:19-24`'s `len(env) > 0` + * check); an unresolved/empty indirection leaves the `env(VAR)` literal + * untouched, same as Go. + */ +function envOverride( + name: string, + configured: string | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const value = projectEnvValues?.[name] ?? process.env[name]; + if (value === undefined || value.length === 0) return configured; + const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (indirection === undefined) return value; + const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; + return resolved !== undefined && resolved.length > 0 ? resolved : value; +} + +/** + * Thrown by {@link legacyEnvOverrideBool} when a `SUPABASE_*_ENABLED` (or other + * bool-typed) env/dotenv override doesn't parse as one of Go's accepted bool + * spellings, mirroring Go's `Config.Load` (`pkg/config/config.go:749-756`): + * `v.UnmarshalExact` decodes with `WeaklyTypedInput` on (viper's + * `defaultDecoderConfig`, never reset by our decoder options — same mechanism + * as {@link LegacyInvalidPortEnvOverrideError}), so mapstructure's `decodeBool` + * runs `strconv.ParseBool` on the override string and hard-fails config + * loading on a bad value — there is no Go code path that reaches `status`/ + * `stop` with a malformed bool override. + */ +export class LegacyInvalidBoolEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + this.name = "LegacyInvalidBoolEnvOverrideError"; + } +} + +/** + * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields Go + * decodes as a native bool (`api.tls.enabled`, `auth.enabled`, and every other + * `
.enabled` gate `status`/`stop` read — see `status.values.ts`) + * rather than a string/number — those are bound by the same generic Viper + * mechanism (`ExperimentalBindStruct` + `SetEnvPrefix("SUPABASE")` + + * `AutomaticEnv()`, `pkg/config/config.go:582-586`), but the override string + * must be decoded with Go's own `strconv.ParseBool` acceptance set + * ({@link legacyParseGoBool}) instead of used verbatim. Unlike a plain string + * override — where an unparsed value has no Go-observable failure mode — a + * malformed bool override is a genuine Go-parity hard failure (see + * {@link LegacyInvalidBoolEnvOverrideError}), same as + * {@link LegacyInvalidPortEnvOverrideError} for ports: Go never proceeds with + * the pre-override value on a decode error, it fails config loading outright. + * + * Exported (not just used internally) because `status.values.ts`'s own + * `
.enabled` gates need this same override treatment — Go's + * `status.toValues()` reads `utils.Config.*.Enabled` post-Viper-override for + * every gated service, not only auth. + */ +export function legacyEnvOverrideBool( + name: string, + configured: boolean, + dottedFieldPath: string, + projectEnvValues: Readonly> | undefined, +): boolean { + const value = envOverride(name, undefined, projectEnvValues); + if (value === undefined) return configured; + const parsed = legacyParseGoBool(value); + if (parsed === undefined) { + throw new LegacyInvalidBoolEnvOverrideError(dottedFieldPath, value); + } + return parsed; +} + +/** + * Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't match one of Go's `LogflareBackend` values. `Analytics.Backend` is + * typed `LogflareBackend` (`pkg/config/config.go:303`), and + * `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects anything + * outside `{postgres, bigquery}` — that runs inside the same + * `v.UnmarshalExact` decode call (`config.go:749-756`) every other + * `SUPABASE_*` override goes through, so a malformed override fails config + * loading outright, same mechanism as {@link LegacyInvalidPortEnvOverrideError}/ + * {@link LegacyInvalidBoolEnvOverrideError}. + */ +export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { + constructor(dottedFieldPath: string, value: string) { + super( + `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + ); + this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; + } +} + +/** + * `analytics.backend`-flavored sibling of {@link envOverridePort}/ + * {@link legacyEnvOverrideBool} for the one `SUPABASE_*` override this file + * decodes as a Go text-unmarshalled enum rather than a string/number/bool — + * see {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. Validates the + * override-or-configured value with a SINGLE check (rather than only + * validating the override, trusting the schema for the configured value), + * matching Go more closely: viper merges the config.toml value and any env + * override into one string BEFORE `UnmarshalExact` calls `UnmarshalText` + * exactly once on the resolved value (`config.go:749-756`), not once per + * source. `@supabase/config`'s `stringEnum` (`packages/config/src/ + * analytics.ts:31-39`) already guards the `config.toml`-sourced value at + * decode time, so this is belt-and-suspenders for that source and the sole + * guard for the env-override one, which bypasses that schema entirely. + */ +function envOverrideAnalyticsBackend( + configured: string, + projectEnvValues: Readonly> | undefined, +): "postgres" | "bigquery" { + const value = + envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + if (value !== "postgres" && value !== "bigquery") { + throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); + } + return value; +} + +/** 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; + if (configured.length < MIN_JWT_SECRET_LENGTH) { + throw new LegacyInvalidJwtSecretError(); + } + return configured; +} + +function resolveOpaqueKey(configured: string | undefined, fallback: string): string { + return configured !== undefined && configured.length > 0 ? configured : fallback; +} + +function resolveSignedKey( + configured: string | undefined, + jwtSecret: string, + signingKey: LegacyJwk | undefined, + role: "anon" | "service_role", +): string { + if (configured !== undefined && configured.length > 0) return configured; + return signingKey !== undefined + ? legacyGenerateAsymmetricGoJwt(signingKey, role) + : legacyGenerateGoJwt(jwtSecret, role); +} + +/** 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. + * + * 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. + * + * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- + * overridden value, not necessarily raw `config.auth.enabled` — see + * {@link legacyEnvOverrideBool}) — Go's `Validate` nests the entire signing-keys read + * inside `if c.Auth.Enabled` (`pkg/config/config.go:1036,1059-1065`), reading + * that same post-override value, 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 = legacyResolveSigningKeysPath(workdir, signingKeysPath); + + let contents: string; + try { + contents = readFileSync(absolutePath, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)); + } + + let jwks: ReadonlyArray; + try { + jwks = decodeLegacyJwks(JSON.parse(contents)); + } catch (cause) { + throw new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)); + } + return jwks[0]; +} + +/** + * Go's `Config.Validate` TLS branch (`pkg/config/config.go:1006-1027`) file reads: gated on + * `api.enabled && api.tls.enabled` same as the caller, each configured path is read to confirm + * it's actually reachable, matching Go's `fs.ReadFile` calls (Go caches the bytes for `start` to + * serve as `CertContent`/`KeyContent` — `status`/`stop` have no use for the bytes, only the same + * validation outcome, so they're discarded here). The "exactly one of cert/key set" presence + * check now lives in `legacyValidateResolvedConfig`'s `api.tls` step + * (`legacy-config-validate.ts`) — this function only runs the reads, and only when BOTH paths + * are actually present: neither path set, or only one, never reaches a `fs.ReadFile` call here, + * since the presence check (run later, as part of the single consolidated validation call) owns + * rejecting the one-but-not-the-other case. + * + * Go joins both paths unconditionally with the `supabase/` dir — no `filepath.IsAbs` guard + * (`config.go:961-965` uses `path.Join`, which absorbs a leading `/`) — unlike + * {@link loadFirstSigningKey}'s `signing_keys_path`, which Go does guard with `filepath.IsAbs` + * (`config.go:928-929`). See `legacyResolveApiTlsPath`. Matches the identical Kong-side + * validation already ported for `seed buckets`/`storage` in + * `legacy-storage-credentials.ts`'s `validateLocalKongTls`. + * + * Uses `node:fs` directly for the same reason as {@link loadFirstSigningKey}: this stays a plain + * synchronous resolver rather than threading the Effect `FileSystem` service through + * `legacyStatusValues`/`status.handler.ts`. + */ +function readApiTlsFiles( + workdir: string, + certPath: string | undefined, + keyPath: string | undefined, +): void { + if (certPath === undefined || certPath.length === 0) return; + if (keyPath === undefined || keyPath.length === 0) return; + + try { + readFileSync(legacyResolveApiTlsPath(workdir, certPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)); + } + try { + readFileSync(legacyResolveApiTlsPath(workdir, keyPath), "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)); + } +} + +/** + * Go's `(e *email) validate(fsys)` template/notification content read (`pkg/config/ + * config.go:1293-1313`), called from `Config.Validate` right after `Auth.MFA.validate()`, still + * inside `if c.Auth.Enabled` (`config.go:1142`). Every template is checked unconditionally; a + * notification only when that notification is itself enabled (`config.go:1308`). Uses the same + * `readFileSync`-based pattern as {@link loadFirstSigningKey}/`readApiTlsFiles` in this file, + * not an Effect `FileSystem` service. + * + * The `content`-vs-`content_path` exclusivity decision and path resolution (including the + * TEMPLATE-vs-`workdir`/NOTIFICATION-vs-`/supabase` base asymmetry, per Go's `(c + * *baseConfig) resolve` (`config.go:900-916`) — this asymmetry is real, intentional Go behavior + * to match, not a bug to fix) now live in `legacyResolveEmailTemplateContentPath` + * (`legacy-config-validate.ts`); this function only feeds it `contentPresent` (computed from the + * raw `document`, since `@supabase/config`'s `template`/`notification` schema + * (`packages/config/src/auth/email.ts`) has no `content` field to see) and performs the read + * when a path comes back. + */ +function readAuthEmailTemplateContent( + email: ProjectConfig["auth"]["email"], + workdir: string, + authDocument: Record | undefined, +): void { + const emailDoc = asRecord(authDocument?.["email"]); + const templatesDoc = asRecord(emailDoc?.["template"]); + const notificationsDoc = asRecord(emailDoc?.["notification"]); + + for (const [name, tmpl] of Object.entries(email.template)) { + const path = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath: tmpl.content_path, + contentPresent: asRecord(templatesDoc?.[name])?.["content"] !== undefined, + base: workdir, + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("template", name, cause), + ); + } + } + for (const [name, tmpl] of Object.entries(email.notification)) { + if (!tmpl.enabled) continue; + const path = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath: tmpl.content_path, + contentPresent: asRecord(notificationsDoc?.[name])?.["content"] !== undefined, + base: join(workdir, "supabase"), + }); + if (path === undefined) continue; + try { + readFileSync(path, "utf8"); + } catch (cause) { + throw new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("notification", name, cause), + ); + } + } +} + +/** + * `SUPABASE_DB_MAJOR_VERSION` sibling of {@link envOverridePort} for the one + * numeric field Go decodes as `uint` rather than `uint16` (`pkg/config/db.go:87`) + * — same generic Viper `AutomaticEnv` binding (`config.go:576-586`), same + * mapstructure hard-fail-on-bad-value semantics as the port/bool overrides, but + * with no upper-bound cap. A non-digit override folds into the same generic + * "Invalid db.major_version" message `legacyValidateResolvedConfig` produces for + * an out-of-set numeric value, since Go's own decode failure and `Validate` + * failure for this field aren't independently distinguishable from the CLI's + * output the way ports/bools are. + */ +function envOverrideMajorVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_DB_MAJOR_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid db.major_version: ${value}.`); + } + return Number(value); +} + +/** + * `SUPABASE_EDGE_RUNTIME_DENO_VERSION` sibling of {@link envOverrideMajorVersion} + * — same generic Viper `AutomaticEnv` binding, same mapstructure + * hard-fail-on-bad-value semantics, no upper-bound cap. A non-digit override + * folds into the same generic "Invalid edge_runtime.deno_version" message + * `legacyValidateResolvedConfig` produces for an out-of-set numeric value. + */ +function envOverrideDenoVersion( + configured: number, + projectEnvValues: Readonly> | undefined, +): number { + const value = envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined, projectEnvValues); + if (value === undefined) return configured; + if (!/^\d+$/.test(value)) { + throw new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${value}.`); + } + return Number(value); +} + +/** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`), used + * only to build {@link legacyResolveLocalConfigValues}'s `hooks` input in the right order — + * the actual per-hook validation now lives in `legacyValidateResolvedConfig`. */ +const LEGACY_HOOK_TYPE_ORDER = [ + "mfa_verification_attempt", + "password_verification_attempt", + "custom_access_token", + "send_sms", + "send_email", + "before_user_created", +] as const; + +/** + * @throws when `project_id` (post-override) is an explicit empty string. Go's + * `Config.Validate` checks this FIRST, before every other field + * (`pkg/config/config.go:990-991`): the workdir-basename default is merged in + * as a viper default value BEFORE `config.toml` is merged + * (`mergeDefaultValues`/`mergeFileConfig`, `config.go:587-593,690-699`), so an + * explicit `project_id = ""` in the file overwrites that default with the + * literal empty string rather than being treated as absent — Go fails outright + * rather than falling back to the basename. A genuinely absent key decodes to + * `undefined` (not `""`, see `packages/config/src/base.ts`'s `optionalKey`), + * so it never trips this check. + * @throws {LegacyInvalidJwtSecretError} when `auth.jwt_secret` is set but too short. + * @throws {LegacyInvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv + * override doesn't parse as a valid port. + * @throws {LegacyInvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv + * override doesn't parse as a valid bool. + * @throws when a configured `api.tls` cert/key file can't be read — see + * {@link readApiTlsFiles}. The "exactly one of cert/key set" presence check + * runs later, as part of {@link legacyValidateResolvedConfig}. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, + * or its first key uses an unsupported algorithm — see {@link loadFirstSigningKey} + * and {@link legacyGenerateAsymmetricGoJwt}. + * @throws when an email template's `content` is present without `content_path`, or a + * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. + * @throws {LegacyInvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` + * doesn't parse as one of Go's `LogflareBackend` values. + * @throws {LegacyConfigValidateError} for every other `Config.Validate` branch this module + * and `legacy-config-validate.ts` jointly own — project_id emptiness aside (checked above, + * inline, since the value is also needed for the throw's own message-free early-exit shape), + * every REMAINING pure check (api.port/tls presence, db.port/major_version, storage bucket + * names, studio, local_smtp, auth.site_url/captcha/passkey/hooks/mfa/smtp/third_party, + * function slugs, edge_runtime.deno_version, analytics.gcp_*, experimental.*) is deferred to a + * SINGLE call to {@link legacyValidateResolvedConfig} at the end of this function, in Go's exact + * relative order — see that module's header for the full table and the accepted ordering + * tradeoff this introduces against the I/O checks listed above (which keep running at their + * original position, per-caller, rather than being folded into that single call). + */ +export function legacyResolveLocalConfigValues( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly> | undefined = undefined, + /** + * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, + * pre-schema-default TOML document `config` was decoded from. Lets checks + * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted + * value) inspect the file directly — see `legacyValidateResolvedConfig`'s + * `experimental.webhooks`/`auth.passkey`/`auth.email.smtp` steps. + * `undefined` for callers that haven't threaded it through yet (e.g. most + * existing unit tests); those checks are then simply skipped rather than + * guessed at. + */ + document: Readonly> | undefined = undefined, +): LegacyLocalConfigValues { + // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field + // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above + // for why an explicit `project_id = ""` fails here while an absent key does + // not. `SUPABASE_PROJECT_ID` is checked via the same `envOverride` precedence + // every other field here uses, since Viper's `AutomaticEnv` binds it too + // (`config.go:529-535`) and it can turn an explicit-empty file value back + // into a valid override. + const resolvedProjectId = envOverride("SUPABASE_PROJECT_ID", config.project_id, projectEnvValues); + + // Go's `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` + // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override + // (`config.go:529-535,799-809`), so the values fed into + // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation (which picks `https` vs `http` from + // `tls.enabled`) must be the overridden ones too. + const apiTlsEnabled = legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); + // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` + // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the + // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const apiTlsCertPath = envOverride( + "SUPABASE_API_TLS_CERT_PATH", + config.api.tls.cert_path, + projectEnvValues, + ); + const apiTlsKeyPath = envOverride( + "SUPABASE_API_TLS_KEY_PATH", + config.api.tls.key_path, + projectEnvValues, + ); + if (apiEnabled && apiTlsEnabled) { + readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + } + // Go's `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY + // when `api.enabled` (`pkg/config/config.go:1006-1008`) — unlike `db.port` + // below, which has no `enabled` gate. Resolved once into a named const so the + // check and the URL derivation below share the same overridden value instead + // of calling `envOverridePort` twice. + const apiPort = envOverridePort( + "SUPABASE_API_PORT", + config.api.port, + "api.port", + projectEnvValues, + ); + const apiExternalUrl = legacyResolveApiExternalUrl( + { + external_url: envOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, + ); + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no + // `enabled` gate in Go's `Config.Validate` — it's unconditionally required, + // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this + // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` + // render anything, same wording already used for the `db query`/`test db` + // path (`legacy-db-config.toml-read.ts:1380`). + const dbPort = envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + // Go's `Config.Validate` checks `db.major_version` right after `db.port` + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). + const majorVersion = envOverrideMajorVersion(config.db.major_version, projectEnvValues); + // Go's `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + // key right after `db.major_version`, unconditionally. + const storageBucketNames = + config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; + // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` + // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same + // enabled-gated pattern as `api.port` above. + const studioEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = envOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); + // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right + // after the port check, still inside `if c.Studio.Enabled` + // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required + // (defaulted) field, so `envOverride` can only return `undefined` here if + // that default itself were somehow undefined — the `??` fallback just + // satisfies that generic signature. + const studioApiUrl = + envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url; + // Go's `Config.Validate` rejects `local_smtp.port === 0`/ + // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct + // field is still named `Inbucket` for the `[local_smtp]` TOML section + // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the + // deprecated `inbucket.enabled` alias are the same underlying flag, not two + // independent ones. + const mailpitEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = envOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + const jwtSecret = resolveJwtSecret( + envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + ); + const signingKeysPath = envOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + 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. `c.Auth.Enabled` is itself Viper-bound like + // any other field (`config.go:582-586`), so `Validate`'s gate reads the + // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence + // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. + const authEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` + // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — + // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT + // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as + // `""` with no schema-level error, same gap as `db.port === 0` above. + const siteUrl = envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues); + // Go's `Config.Validate` checks `auth.captcha` right after `auth.site_url`, + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1099-1109`): an + // enabled CAPTCHA section requires both `provider` and `secret`. Read + // directly off `config.auth.captcha` (not through `envOverride`) — unlike + // the flat `auth.site_url`/`auth.signing_keys_path` fields, there's no + // established `SUPABASE_AUTH_CAPTCHA_*` env-override precedent elsewhere in + // this file for this nested optional auth sub-section. `config.auth.captcha` + // is genuinely `undefined` when `[auth.captcha]` is absent from config.toml + // (`Schema.optionalKey` in `packages/config/src/auth/index.ts`), matching + // Go's `*Captcha` pointer being nil. + const captchaInput: LegacyCaptchaInput | undefined = config.auth.captcha + ? { + enabled: config.auth.captcha.enabled ?? false, + provider: config.auth.captcha.provider, + secret: config.auth.captcha.secret, + } + : undefined; + const signingKey = + authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? loadFirstSigningKey(workdir, signingKeysPath) + : undefined; + // Go's `Config.Validate` runs passkey/webauthn validation, then + // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then + // `Auth.Email.validate()`, then (skipping the unported Sms/External steps) + // `Auth.ThirdParty.validate()`, all right after the signing-keys read and + // still inside `if c.Auth.Enabled` (`pkg/config/config.go:1117-1153`). + // Sms/External (`config.go:1145-1150`) aren't ported (D-only, see + // `legacy-config-validate.ts`'s module header). This block only ACCUMULATES + // the inputs those checks need — the checks themselves run once, later, as + // part of the single `legacyValidateResolvedConfig` call below. + let authInput: LegacyAuthInput | undefined; + if (authEnabled) { + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see + // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn + // are read from the RAW, post-`env()`-interpolation TOML document + // (`LoadedProjectConfig.document`) instead of the decoded `ProjectConfig` — same document-based + // approach already used on the `db`/migration config-load path + // (`legacy-db-config.toml-read.ts`'s `legacyValidateAuthConfig`, section A6). `authDocument` is + // `undefined` when a caller hasn't threaded `document` through yet, in which case passkey/smtp + // presence-based checks are simply skipped rather than guessed at. + const authDocument = asRecord(document?.["auth"]); + const passkeyDoc = asRecord(authDocument?.["passkey"]); + const webauthnDoc = asRecord(authDocument?.["webauthn"]); + const passkey: LegacyPasskeyInput | undefined = + passkeyDoc?.["enabled"] === true + ? { + webauthnPresent: webauthnDoc !== undefined, + rpId: typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined, + rpOrigins: Array.isArray(webauthnDoc?.["rp_origins"]) + ? webauthnDoc["rp_origins"] + : undefined, + } + : undefined; + + // Go's `hook.validate()` fixed iteration order (`pkg/config/config.go:1453-1485`) — only + // enabled hooks are forwarded, in that order. + const hooks: Array = []; + for (const hookType of LEGACY_HOOK_TYPE_ORDER) { + const hook = config.auth.hook[hookType]; + if (hook.enabled) { + hooks.push({ type: hookType, uri: hook.uri ?? "", secrets: hook.secrets ?? "" }); + } + } + + const mfa: ReadonlyArray = [ + { + label: "totp", + enrollEnabled: config.auth.mfa.totp.enroll_enabled, + verifyEnabled: config.auth.mfa.totp.verify_enabled, + }, + { + label: "phone", + enrollEnabled: config.auth.mfa.phone.enroll_enabled, + verifyEnabled: config.auth.mfa.phone.verify_enabled, + }, + { + label: "web_authn", + enrollEnabled: config.auth.mfa.web_authn.enroll_enabled, + verifyEnabled: config.auth.mfa.web_authn.verify_enabled, + }, + ]; + + // Go's `Config.Validate` runs the email template/notification content read right after + // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read + // stays at this exact textual position (see this function's `@throws` doc for why). + readAuthEmailTemplateContent(config.auth.email, workdir, authDocument); + + // Go's `[auth.email.smtp]` presence-based `enabled` default (`pkg/config/config.go:743-748`): + // when the TOML table is present but omits `enabled`, Go treats it as `true` — a genuinely + // presence-based default `@supabase/config`'s schema can't see (it always decodes + // `smtp.enabled` to `false` when the key is absent), so this reads the raw `document` too. + const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); + const smtp: LegacySmtpInput | undefined = + smtpDoc !== undefined + ? { + enabled: smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + host: typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", + port: typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + user: typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", + pass: typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + adminEmail: typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", + } + : undefined; + + // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) + // — only enabled providers are forwarded, in that order. + const thirdParty: Array = []; + if (config.auth.third_party.firebase.enabled) { + thirdParty.push({ + provider: "firebase", + requiredField: config.auth.third_party.firebase.project_id ?? "", + }); + } + if (config.auth.third_party.auth0.enabled) { + thirdParty.push({ + provider: "auth0", + requiredField: config.auth.third_party.auth0.tenant ?? "", + }); + } + if (config.auth.third_party.aws_cognito.enabled) { + thirdParty.push({ + provider: "cognito", + requiredField: config.auth.third_party.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: config.auth.third_party.aws_cognito.user_pool_region, + }); + } + if (config.auth.third_party.clerk.enabled) { + thirdParty.push({ + provider: "clerk", + requiredField: config.auth.third_party.clerk.domain ?? "", + }); + } + if (config.auth.third_party.workos.enabled) { + thirdParty.push({ + provider: "workos", + requiredField: config.auth.third_party.workos.issuer_url ?? "", + }); + } + + authInput = { + siteUrl: siteUrl ?? "", + captcha: captchaInput, + passkey, + hooks, + mfa, + smtp, + thirdParty, + }; + } + // Go's `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` + // key right after the auth block/`generateAPIKeys`, unconditionally. + const functionSlugs = Object.keys(config.functions); + // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth + // block and the functions loop (`pkg/config/config.go:1158-1173`), and — + // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no + // `edge_runtime.enabled` gate. + const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order, each with its own message. + // Backend-enum validation (rejecting a non-postgres/bigquery value) is + // covered at decode time for the `config.toml`-sourced value by + // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), + // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override + // path — see {@link envOverrideAnalyticsBackend} for that case. + const analyticsEnabled = legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); + const gcpProjectId = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = envOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = envOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); + + // Go's `Config.Validate` calls `c.Experimental.validate()` right after the + // analytics/bigquery block and right before returning. The webhooks check is NOT "the user + // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && + // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` + // isn't explicitly `true`, including one where the key is simply omitted; the section exists + // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML + // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default + // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills + // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the + // TOML section is entirely absent — verified empirically, this default-fill erases exactly the + // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, + // pre-default TOML) instead, same as the passkey/smtp checks above. + const experimentalDocument = asRecord(document?.["experimental"]); + const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is + // deferred to this single call, positioned here (where the last of those checks ran until + // this commit), in Go's exact relative order against every OTHER pure check. This means a + // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers + // first among the ones broken — unchanged from before. The only real reordering risk is + // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls + // cert/key, email template/notification content) that in THIS function's source sits between + // two pure sections (e.g. the signing-keys read sits between the captcha check above and the + // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O + // read now effectively runs BEFORE those later pure checks rather than interleaved at its + // original relative position. This is the same narrow, accepted, documented tradeoff recorded + // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one + // validation failure at a time, so it has zero effect on any real test. + const apiInput: LegacyApiInput = { + enabled: apiEnabled, + port: apiPort, + tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, + }; + const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; + const studioInput: LegacyStudioInput = { + enabled: studioEnabled, + port: studioPort, + apiUrl: studioApiUrl, + }; + const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + }; + const experimentalInput: LegacyExperimentalInput = { + webhooksPresent, + webhooksEnabled, + pgdeltaFormatOptions, + }; + + const input: LegacyConfigValidationInput = { + projectId: resolvedProjectId, + api: apiInput, + db: dbInput, + storageBucketNames, + studio: studioInput, + localSmtp: localSmtpInput, + auth: authInput, + functionSlugs, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + legacyValidateResolvedConfig(input); + + 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}:${studioPort}`, + mailpitUrl: `http://${hostname}:${mailpitPort}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + publishableKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", config.auth.publishable_key, projectEnvValues), + defaultPublishableKey, + ), + secretKey: resolveOpaqueKey( + envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + defaultSecretKey, + ), + jwtSecret, + anonKey: resolveSignedKey( + 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, projectEnvValues), + jwtSecret, + signingKey, + "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..d1b0afe7c2 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -0,0 +1,1186 @@ +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 { importJWK, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyInvalidAnalyticsBackendEnvOverrideError, + LegacyInvalidBoolEnvOverrideError, + LegacyInvalidJwtSecretError, + LegacyInvalidPortEnvOverrideError, + 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" }; +} + +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(); + 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"); + 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", 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", 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", 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", 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", 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"); + }); + + 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", 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", 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("."); + 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", 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", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); + 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", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); + + it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + // Go's Config.Validate checks ProjectId first, before any other field + // (pkg/config/config.go:990-991). The workdir-basename default is merged + // in as a viper default BEFORE config.toml is merged, so an explicit + // `project_id = ""` in the file overwrites that default with the literal + // empty string rather than being treated as absent — Go fails outright. + const config = baseConfig({ project_id: "" }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: project_id", + ); + }); + + it("does not reject an absent project_id (the workdir-basename default applies elsewhere)", () => { + const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { + // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence + // than config.toml (config.go:529-535), so a non-empty env override must + // win even when the file's project_id is explicitly empty. + const config = baseConfig({ project_id: "" }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); + + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + 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", () => { + 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 = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + ] 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, + ); + }); + + 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("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:15-23`) is + // the first mapstructure decode hook composed into `v.UnmarshalExact` + // (`config.go:749-753,769-772`), so it resolves a nested `env(VAR)` + // reference on ANY string mapstructure decodes into the struct — including + // a `SUPABASE_*` env-override value itself, not just a `config.toml` + // literal. `envOverride`'s callers (string/port/bool fields) must all see + // that same resolution. + const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + delete process.env["INDIRECT_JWT_SECRET"]; + delete process.env["INDIRECT_DB_PORT"]; + delete process.env["INDIRECT_API_ENABLED"]; + }); + + it("resolves a string override's env(VAR) indirection", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("c".repeat(32)); + }); + + it("resolves a port override's env(VAR) indirection", () => { + process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; + process.env["INDIRECT_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("resolves a bool override's env(VAR) indirection", () => { + process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; + process.env["INDIRECT_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + // If the bool override weren't resolved through the indirection, the + // literal "env(INDIRECT_API_ENABLED)" string would fail Go's + // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; + // resolving it to "false" disables api.enabled and skips the TLS check + // that would otherwise throw on the missing cert file. + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Go's LoadEnvHook only substitutes when the target var is non-empty + // (`decode_hooks.go:19-24`) — an unset indirection leaves the literal + // `env(VAR)` string, same as an unresolved config.toml-level reference. + expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + }); + }); + + describe("non-auth SUPABASE_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // generically across the whole config struct (pkg/config/config.go:529-535), + // not just auth fields — config_test.go:351,1061 exercise this against + // auth.site_url, and status.go's toValues() reads the already-overridden + // utils.Config.* directly, so every port/URL status derives must honor the + // same override. + const ENV_KEYS = [ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_STUDIO_API_URL", + ] as const; + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("overrides db.port for the derived DB URL", () => { + process.env["SUPABASE_DB_PORT"] = "54329"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); + + it("overrides studio.port for the derived Studio URL", () => { + process.env["SUPABASE_STUDIO_PORT"] = "54330"; + const config = baseConfig({ studio: { port: 54323 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.studioUrl).toBe("http://127.0.0.1:54330"); + }); + + it("overrides local_smtp.port for the derived Mailpit URL", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; + const config = baseConfig({ local_smtp: { port: 54324 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); + }); + + it("overrides api.port for every API-derived URL", () => { + process.env["SUPABASE_API_PORT"] = "54332"; + const config = baseConfig({ api: { port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54332"); + expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); + }); + + it("overrides api.external_url even when config.toml sets one", () => { + process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; + const config = baseConfig({ api: { external_url: "https://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://env-override.example"); + }); + + it("treats an empty non-auth env var as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_PORT"] = ""; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); + + // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via + // Viper's UnmarshalExact (pkg/config/config.go:749-756, WeaklyTypedInput + // decodes the override string with strconv.ParseUint and hard-fails on a + // malformed value) rather than silently producing a `NaN`-laced URL. + it.each([ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { + process.env[envKey] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + process.env["SUPABASE_DB_PORT"] = "99999"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidPortEnvOverrideError, + ); + }); + + // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, + // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a + // Config.Validate-time hard-fail specific to db.port: it has no `enabled` + // gate in Go, unlike api.port/studio.port/local_smtp.port + // (pkg/config/config.go:1006-1009,1031-1032,1070-1073,1081-1084). + it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { + process.env["SUPABASE_DB_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.port", + ); + }); + + // Unlike db.port, Go gates the api.port===0 rejection on api.enabled + // (pkg/config/config.go:1006-1008) — api.enabled defaults to true, so a + // configured or env-overridden zero port is rejected by default. + it("rejects a configured api.port of 0 when api is enabled", () => { + const config = baseConfig({ api: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { + process.env["SUPABASE_API_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); + + it("does not reject a zero api.port when api is disabled", () => { + const config = baseConfig({ api: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go gates the studio.port===0 rejection on studio.enabled + // (pkg/config/config.go:1070-1073), same pattern as api.port above. + // studio.enabled defaults to true, so a configured or env-overridden zero + // port is rejected by default. + it("rejects a configured studio.port of 0 when studio is enabled", () => { + const config = baseConfig({ studio: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { + process.env["SUPABASE_STUDIO_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); + + it("does not reject a zero studio.port when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's Config.Validate parses studio.api_url with net/url.Parse right + // after the port check, still inside `if c.Studio.Enabled` + // (pkg/config/config.go:1074-1078). + it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + it("does not reject a malformed studio.api_url when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("does not throw for the default studio.api_url", () => { + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { + process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); + + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's + // struct field is still named `Inbucket` for the `[local_smtp]` TOML + // section, pkg/config/config.go:235,1081-1083), same pattern as api.port/ + // studio.port above. local_smtp.enabled defaults to true, so a configured + // or env-overridden zero port is rejected by default. + it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { + process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); + + it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { + const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("db.major_version (required field in config)", () => { + // The pure 0/12/13-17/generic-invalid assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_DB_MAJOR_VERSION"]; + }); + + it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: 16.", + ); + }); + + it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: abc.", + ); + }); + + it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] + // key right after db.major_version, unconditionally — there is no + // storage.enabled-style gate (pkg/config/config.go:1063-1068). + // + // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — this section has no L-specific derivation or env-override mechanics of its own. + + // Go's Config.Validate rejects an invalid edge_runtime.deno_version + // unconditionally — NOT gated on edge_runtime.enabled + // (pkg/config/config.go:1164-1173). + describe("edge_runtime.deno_version (required field in config)", () => { + // The pure 0/1/2/generic-invalid/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + }); + + it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); + + it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + }); + + it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig(); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); + + it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("analytics (BigQuery backend required fields)", () => { + // Go's `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order. + // + // The pure required-field/complete/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. + afterEach(() => { + delete process.env["SUPABASE_ANALYTICS_ENABLED"]; + delete process.env["SUPABASE_ANALYTICS_BACKEND"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + }); + + it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); + + it("accepts env-provided GCP fields overriding empty config.toml values", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; + process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + // Go's `LogflareBackend.UnmarshalText` (`config.go:60-65`) hard-rejects any + // `analytics.backend` value outside `postgres`/`bigquery` during the same + // `UnmarshalExact` decode every `SUPABASE_*` override goes through + // (`config.go:749-756`) — a malformed `SUPABASE_ANALYTICS_BACKEND` fails + // config loading outright, same mechanism as the port/bool overrides below. + it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { + process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidAnalyticsBackendEnvOverrideError, + ); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + ); + }); + }); + + describe("experimental.* (experimental.validate())", () => { + // Go's `(e *experimental) validate()` (`pkg/config/config.go:1846-1854`), + // called right after the analytics/bigquery block and right before + // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. + // + // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks + // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of + // deriving them from a raw `document`) — only this document-THREADING-specific case stays + // here, since it exercises this function's own "no document provided" fallback rather than + // a check `legacyValidateResolvedConfig` itself owns. + it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { + // No `document` (5th param) at all — e.g. a caller that hasn't threaded + // `LoadedProjectConfig.document` through yet. The presence-only check + // can't run without it, so it's skipped rather than guessed at; this + // also covers every pre-existing call site/test in this file that + // doesn't pass a 5th argument. + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + + describe("SUPABASE_API_TLS_ENABLED env override", () => { + // Go applies the Viper-bound `api.tls.enabled` override (config.go:582-586) + // BEFORE deriving the default `api.external_url` scheme (config.go:799-809), + // so an ambient/dotenv override flips http/https even when config.toml says + // otherwise. + afterEach(() => { + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("overrides api.tls.enabled from false to true", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + + it("overrides api.tls.enabled from true to false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "false"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); + + it("does not override api.tls.enabled once api.external_url is set", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ api: { external_url: "http://config.example" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://config.example"); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + + it("treats an empty override as unset, matching Viper's default", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = ""; + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); + }); + + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + + 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 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( + "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", () => { + 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", + ); + }); + + // 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", + }); + }); + + describe("SUPABASE_AUTH_ENABLED env override", () => { + // `c.Auth.Enabled` is Viper-bound like any other field + // (config.go:582-586), so `Validate`'s `if c.Auth.Enabled` gate + // (config.go:1036,1059-1065) reads the POST-override value, not raw + // TOML — a stale/missing signing_keys_path must be skipped when auth is + // disabled only via env/dotenv, and read when auth is enabled only via + // env/dotenv despite TOML saying otherwise. + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ + auth: { enabled: true, signing_keys_path: "missing.json" }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); + }); + + it("rejects a malformed override instead of falling back to the configured value", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); + }); + }); + + describe("auth.site_url (required field in config)", () => { + // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / + // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. + describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_ENABLED"]; + delete process.env["SUPABASE_AUTH_SITE_URL"]; + }); + + it("rejects an empty site_url when auth is enabled only via env", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "true"; + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.site_url", + ); + }); + + it("does not throw when auth is disabled only via env, however empty site_url is", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "false"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + + it("accepts an env-provided site_url overriding an empty config.toml value", () => { + process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); + }); + }); + + // auth.captcha (required fields when enabled) moved entirely to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — L + // reads `config.auth.captcha` directly with no env-override mechanics of its own. + + // auth.passkey / auth.webauthn (WebAuthn requirement when passkey enabled) and + // auth.email.smtp (present-table-implies-enabled) moved entirely to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls, + // setting `auth.passkey`/`auth.smtp` directly instead of deriving them from a raw + // `document`) — both are pure, document-presence-based checks with no env-override variant. + + // auth.hook.* (URI/secret validation when enabled) and auth.mfa.* (enroll_enabled requires + // verify_enabled) moved entirely to `legacy-config-validate.unit.test.ts` (direct + // `legacyValidateResolvedConfig` calls) — L pre-filters to enabled-only hooks/derives mfa + // directly off `config.auth.*` with no env-override mechanics of its own for these checks. + + describe("auth.email.template/notification (content_path validation)", () => { + // Go's `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`), + // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + const tempRoot = useLegacyTempWorkdir("supabase-email-templates-test-"); + + it("rejects a template content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a template with no content_path configured", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("rejects an enabled notification content_path pointing at a missing file", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }); + + it("resolves a relative notification content_path against /supabase", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a disabled notification's missing content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("does not throw a missing template content_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric + // content-vs-content_path exclusivity (`config.go:1293-1313`) — a raw `content` key present + // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no + // `content` field to see, so this only fires when the raw `document` (5th param) carries it. + it("rejects a template content key present without content_path", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + ).toThrow( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }); + }); + + // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) + // moved entirely to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — L pre-filters to enabled-only third_party providers and derives function slugs + // directly off `config.functions` with no env-override mechanics of its own for these checks. + + describe("api.tls (cert/key validation)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); + + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } + + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // The "exactly one of cert/key set" presence-only assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // the actual file reads below stay here, since I/O is per-caller. + + it("throws a Go-worded error when the configured cert file does not exist", () => { + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + + it("throws a Go-worded error when the configured key file does not exist", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); + + it("succeeds when both cert_path and key_path are readable", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` + // (config.go:1006,1010) — a disabled api section never validates cert/key, + // however invalid the pairing. + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_API_ENABLED"]; + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("skips TLS validation when api is disabled only via env", () => { + process.env["SUPABASE_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + }); + }); +}); 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..fe1ad08291 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { ProjectEnvironment } from "@supabase/config"; + +import { parseDotEnv } from "./legacy-dotenv.ts"; + +/** + * 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 dotenv reader for the project-root and `SUPABASE_ENV`-selected extra + * files this module resolves, 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. Delegates to {@link parseDotEnv} — the same `godotenv`-faithful, + * cursor-based parser `bootstrap`/`legacyReadDbToml` already use — rather than + * a hand-rolled line-by-line scan, so a quoted value spanning physical lines + * (a PEM/private key) parses correctly instead of aborting on what looks like + * a malformed continuation line. + * + * @throws on a line that isn't blank, a comment, or a `KEY=VALUE`/`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. + */ +function readDotEnvFile(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + + const contents = readFileSync(path, "utf8"); + try { + return parseDotEnv(contents); + } catch (cause) { + throw new Error( + `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + ); + } +} + +/** + * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` + * 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). + * + * `projectEnv` is `null` whenever `@supabase/config` found no + * `supabase/config.toml`/`config.json` (searching ancestors, or at exactly + * `workdir` when the caller passed `search: false`) — but Go's dotenv loading + * doesn't share that precondition: `Config.Load` calls + * `loadNestedEnv(builder.SupabaseDirPath)` BEFORE it ever opens `config.toml` + * (`pkg/config/config.go:786-793`), and `SupabaseDirPath` is a pure string + * join with no existence check (`NewPathBuilder`, `pkg/config/utils.go:43-48`). + * So a missing/absent config file must not skip dotenv loading — fall back to + * deriving the same two directories directly from `workdir` + * (`/supabase` and `workdir` itself) and read `process.env` itself as + * the ambient layer, since there's no `loadProjectEnvironment` result to + * consult for it in this branch. + */ +export function legacyResolveProjectEnvironmentValues( + projectEnv: ProjectEnvironment | null, + workdir: string, +): Record { + const env = process.env["SUPABASE_ENV"] || "development"; + const filenames = candidateDotenvFilenames(env); + const merged: Record = {}; + + const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); + const projectRoot = projectEnv?.paths.projectRoot ?? workdir; + + // 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 [supabaseDir, 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; + } + } + } + + const ambientOverrides: Record = {}; + if (projectEnv !== null) { + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } + } + } else { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + 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 new file mode 100644 index 0000000000..8e7e71b826 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -0,0 +1,300 @@ +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"]; + delete process.env["SUPABASE_PROJECT_ID"]; +}); + +function fakeProjectEnv( + values: Record = {}, + sources: Record = {}, +): ProjectEnvironment { + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: join(supabaseDir, "config.toml"), + envPath: join(supabaseDir, ".env"), + envLocalPath: join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + // 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"])), + }; +} + +describe("legacyResolveProjectEnvironmentValues", () => { + it("returns just the already-loaded values when no extra dotenv files exist", () => { + const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); + expect(legacyResolveProjectEnvironmentValues(projectEnv, root)).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(), root); + 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(), root); + 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, root); + 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(), root); + 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(), root); + 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(), root); + 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(), root); + 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(), root); + 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(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }); + + it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { + // godotenv only starts an inline comment at a `#` preceded by whitespace + // (`godotenv@v1.5.1/parser.go:144-153`); `foo#bar` keeps the `#` verbatim. + writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }); + + it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); + }); + + it("strips a trailing comment after a quoted value, matching godotenv", () => { + // godotenv's `extractVarValue` locates the quoted span by scanning forward for the + // closing quote (`godotenv@v1.5.1/parser.go:160-180`) and discards anything after + // it as a comment — the value is `demo`, not the literal `"demo"` a check that + // requires the whole trimmed remainder to end with a quote would produce. + writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { + // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators + // (`godotenv@v1.5.1/parser.go:90-95`), and the repo's other dotenv parser + // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. + writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-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, root); + 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, root); + 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(), root)).toThrow( + /failed to parse environment file/, + ); + }); + + it("expands an unquoted $VAR reference to an earlier value in the same file", () => { + // godotenv expands unquoted/double-quoted references while loading + // (`godotenv@v1.5.1/parser.go:157`), so a later key can reuse an earlier one. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }); + + it("expands a braced ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }); + + it("does not expand variable references inside single-quoted values", () => { + // godotenv never calls expandVariables for single-quoted values + // (`parser.go:172-173`) — they stay byte-literal. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }); + + it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }); + + it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { + writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }); + + it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { + // godotenv's expandVarRegex captures a leading backslash and strips ONLY + // that backslash, returning the rest of the match verbatim instead of + // doing a lookup (`godotenv@v1.5.1/parser.go:253,264-265`) — even when + // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become + // `demodemo`. + writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }); + + it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { + writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }); + + it("treats a bare trailing $ with no variable name as a literal", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }); + + it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { + // godotenv's parser scans the whole buffer with a cursor, not line-by-line + // (`godotenv@v1.5.1/parser.go:20-45`), so a quoted value spanning physical + // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest + // of the file. A naive line-by-line reader would see the continuation line + // as malformed and abort before SUPABASE_PROJECT_ID is ever read. + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + writeFileSync( + join(root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }); + + describe("when no project was found (projectEnv is null)", () => { + // Go's `loadNestedEnv` runs unconditionally before `config.toml` is ever + // opened (`pkg/config/config.go:786-793`), so a missing config file must + // not skip dotenv loading — these cover the local fallback that derives + // `/supabase`/`workdir` directly instead of giving up. + + it("still reads a supabase/-dir dotenv file directly under workdir", () => { + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }); + + it("still reads a project-root dotenv file directly under workdir", () => { + writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }); + + it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { + 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(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }); + + it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { + process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; + writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }); + + it("returns an empty object when workdir has no dotenv files and no ambient value", () => { + const merged = legacyResolveProjectEnvironmentValues(null, root); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 382d16e6b1..0ec17002b5 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -144,8 +144,8 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // Load config.toml, passing projectRef so `[remotes.*]` overrides are merged for // --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; + const loadOptions: LoadProjectConfigOptions = + projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( Effect.catchTag( "ProjectConfigParseError", 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/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 4401c56387..0810c61872 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -155,6 +155,62 @@ function hostFromAuthority(authority: string): string { return at === -1 ? authority : authority.slice(at + 1); } +/** Go `net/url.validOptionalPort` (`url.go:761-774`): `""`, or `:` followed by only digits. */ +function isValidOptionalPort(port: string): boolean { + if (port.length === 0) return true; + if (port.charCodeAt(0) !== 0x3a /* : */) return false; + for (let i = 1; i < port.length; i++) { + if (!isDigit(port.charCodeAt(i))) return false; + } + return true; +} + +/** + * Go `net/url.parseHost`, restricted to the branches this module's callers + * can actually hit (`net/url/url.go:544-608` in the Go stdlib `apps/cli-go` + * builds against): IP-literal (`[...]`) bracket/port validation, and the bare + * `host[:port]` port-digit check. Deliberately does NOT implement IPv6 + * zone-identifier percent-decoding or `netip.ParseAddr` address validation + * (Go additionally requires a bracketed literal to parse as a valid, + * non-IPv4 IP address) — callers only need to know whether validation + * THROWS, and a config author writing a bogus-but-bracket-balanced IPv6 + * literal is exotic enough that the narrower `netip.ParseAddr` check isn't + * worth the added surface; the common bracket-mismatch/invalid-port mistakes + * Go actually raises for realistic input (e.g. `http://[::1`, a truncated + * IPv6 literal) are still reproduced byte-for-byte. Also skips Go's + * `GODEBUG=urlstrictcolons=0` legacy multi-colon opt-out (`url.go:596-604`) — + * an explicit, non-default env var this codebase's bundled Go binary doesn't + * set, so the http/https "first colon is the port separator" behavior always + * applies for those schemes; other schemes use the last colon, matching Go's + * unconditional `else` branch for non-http(s) schemes. + */ +function validateGoUrlHost(scheme: string, host: string): void { + if (host.length === 0) return; + const openBracketIdx = host.indexOf("["); + if (openBracketIdx > 0) { + throw new Error("invalid IP-literal"); + } + if (openBracketIdx === 0) { + const closeBracketIdx = host.lastIndexOf("]"); + if (closeBracketIdx < 0) { + throw new Error("missing ']' in host"); + } + const colonPort = host.slice(closeBracketIdx + 1); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + return; + } + const colonIdx = + scheme === "http" || scheme === "https" ? host.indexOf(":") : host.lastIndexOf(":"); + if (colonIdx !== -1) { + const colonPort = host.slice(colonIdx); + if (!isValidOptionalPort(colonPort)) { + throw new Error(`invalid port ${JSON.stringify(colonPort)} after host`); + } + } +} + /** * Port of Go's `url.Parse` restricted to `Scheme`/`Host`/`Path`. Throws * `LegacyGoUrlParseError` (Go's `*url.Error`) on the failures the storage @@ -208,6 +264,11 @@ export function legacyGoUrlParse(rawURL: string): LegacyGoUrl { const authority = slash === -1 ? afterSlashes : afterSlashes.slice(0, slash); rest = slash === -1 ? "" : afterSlashes.slice(slash); host = hostFromAuthority(authority); + try { + validateGoUrlHost(scheme, host); + } catch (cause) { + throw new LegacyGoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + } } let path: string; diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts index 5e9643f48e..6100802123 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.unit.test.ts @@ -4,6 +4,7 @@ import { LegacyGoUrlParseError, LegacyStorageUrlPatternError, legacyDetectScheme, + legacyGoUrlParse, legacyParseStorageUrl, legacySplitBucketPrefix, legacyStorageIsDir, @@ -85,6 +86,52 @@ describe("legacyDetectScheme", () => { }); }); +// Oracle: `go run` against Go 1.25's `net/url.Parse` directly (net/url/url.go's +// `parseHost`) — used by `studio.api_url` validation, which needs the Host, not +// just Scheme/Path, so the failures below matter beyond the storage commands. +describe("legacyGoUrlParse (host validation)", () => { + it("rejects an unterminated IPv6 literal", () => { + expect(() => legacyGoUrlParse("http://[::1")).toThrow(LegacyGoUrlParseError); + expect(() => legacyGoUrlParse("http://[::1")).toThrow( + `parse "http://[::1": missing ']' in host`, + ); + }); + + it("accepts a bracketed IPv6 literal with no port", () => { + expect(legacyGoUrlParse("http://[::1]").host).toBe("[::1]"); + }); + + it("accepts a bracketed IPv6 literal with a valid port", () => { + expect(legacyGoUrlParse("http://[::1]:8080").host).toBe("[::1]:8080"); + }); + + it("rejects a bracketed IPv6 literal with a non-numeric port", () => { + expect(() => legacyGoUrlParse("http://[::1]:abc")).toThrow( + `parse "http://[::1]:abc": invalid port ":abc" after host`, + ); + }); + + it("rejects a bracket that isn't the first character of the host", () => { + expect(() => legacyGoUrlParse("http://host[::1]")).toThrow( + `parse "http://host[::1]": invalid IP-literal`, + ); + }); + + it("accepts a plain hostname with a numeric port", () => { + expect(legacyGoUrlParse("http://example.com:99999").host).toBe("example.com:99999"); + }); + + it("rejects more than one colon in an http(s) host (strict-colon default)", () => { + expect(() => legacyGoUrlParse("http://host:1:2")).toThrow( + `parse "http://host:1:2": invalid port ":1:2" after host`, + ); + }); + + it("does not validate a host for a scheme with no authority (ss:///bucket)", () => { + expect(() => legacyGoUrlParse("ss:///bucket/x")).not.toThrow(); + }); +}); + describe("legacyStorageIsDir", () => { it.each([ ["", true], diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts new file mode 100644 index 0000000000..40e8a5976f --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -0,0 +1,52 @@ +import { Data, Effect, FileSystem } from "effect"; + +/** + * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path + * doesn't exist or isn't a directory. Callers map this into their own + * command-specific error type. + */ +export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ + readonly message: string; +}> {} + +/** + * Validates that `workdir` exists and is a directory, the way Go's + * `ChangeWorkDir` implicitly does via `os.Chdir` (`apps/cli-go/internal/utils/ + * misc.go:231-250`, called from `PersistentPreRunE`, `apps/cli-go/cmd/root.go: + * 93-105`, before any command runs): a missing path or a path that isn't a + * directory fails immediately, before config load or any Docker/API access. + * + * Callers that resolve `workdir` via `LegacyCliConfig` only need this check + * when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — `legacy-cli-config. + * layer.ts`'s default walk-up-for-`supabase/config.toml` resolution always + * returns a real, already-existing directory (either one containing + * `supabase/config.toml`, or the process's own `cwd`), so it can never fail + * this check; calling it unconditionally is therefore safe and simpler than + * threading "was this explicit?" through every caller. + */ +export function legacyValidateWorkdirIsDirectory( + workdir: string, + fs: FileSystem.FileSystem, +): Effect.Effect { + return fs.stat(workdir).pipe( + Effect.matchEffect({ + onFailure: (error) => { + const reason = + error.reason._tag === "NotFound" ? "no such file or directory" : error.message; + return Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: ${reason}`, + }), + ); + }, + onSuccess: (info) => + info.type === "Directory" + ? Effect.void + : Effect.fail( + new LegacyWorkdirValidationError({ + message: `failed to change workdir: chdir ${workdir}: not a directory`, + }), + ), + }), + ); +} diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index 1b61948324..acd17b7a1b 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -34,6 +34,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( projectRoot: projectHome.projectRoot, supabaseDir: projectHome.supabaseDir, dashboardUrl: cliConfig.dashboardUrl, + goViperCompat: false, yes: flags.yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index b31bdd35b9..983a7a6a5e 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { @@ -147,4 +147,79 @@ literal = "literal-secret" Effect.provide(projectLayer(cwd)), ); }); + + it.live("does not resolve a lowercase-named env() reference in edge runtime secrets", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "lowercase_env_var=should-not-resolve\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +lowercase_secret = "env(lowercase_env_var)" +`, + ), + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: { + LOWERCASE_SECRET: "env(lowercase_env_var)", + }, + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); + + it.live("does not split a comma-separated string literal for an array field", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), + ); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "test" + +[auth] +additional_redirect_urls = "http://a,http://b" + +[edge_runtime] +policy = "oneshot" +inspector_port = 8123 + +[edge_runtime.secrets] +api_key = "env(EDGE_API_KEY)" +literal = "literal-secret" +`, + ), + ); + + const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); + }); }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 7179a25000..be7b06c555 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,7 +1,12 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Exit, Fiber, Layer } from "effect"; import type { StackServiceStatus } from "@supabase/stack"; import { DEFAULT_VERSIONS, stackMetadata, type StackInfo } from "@supabase/stack/effect"; +import { loadProjectConfig } from "@supabase/config"; import { start } from "./start.handler.ts"; import { StartVersionState } from "./start.command.ts"; import { startForegroundWithStopSignal } from "./flows/foreground.flow.ts"; @@ -532,3 +537,68 @@ describe("start", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("next start config-load path (goViperCompat default-off regression)", () => { + // start.command.ts's `Command.provide` factory calls + // `loadProjectConfig(projectHome.projectRoot)` with no options (line ~183) — + // deeply inside `Layer.unwrap`/`StateManager`/daemon-layer construction that + // this file's `start()`-from-handler harness (StartVersionState provided + // directly) never reaches and can't be extended to reach without + // reproducing that machinery. This test instead exercises the exact + // zero-options `loadProjectConfig` call directly against a real temp + // project, pinning that `next start` still loads successfully when a + // config.toml has a duplicate or malformed `[remotes.*]` block — the + // regression that motivated gating these Go-viper checks behind + // `goViperCompat` (packages/config/src/io.unit.test.ts has the + // authoritative, broader coverage of this default-off behavior). + it("loads successfully despite a duplicate [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("loads successfully despite a malformed [remotes.*] project_id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-next-start-config-")); + try { + await mkdir(join(tempDir, "supabase"), { recursive: true }); + await writeFile( + join(tempDir, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const result = await Effect.runPromise( + loadProjectConfig(tempDir).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result).not.toBeNull(); + expect(result?.config.project_id).toBe("baseref"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); 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"], ]); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index b5d7be72a0..51f0d9a9a8 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -62,6 +62,7 @@ interface DeployFunctionsDependencies { readonly projectRoot: string; readonly supabaseDir: string; readonly dashboardUrl: string; + readonly goViperCompat: boolean; readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; @@ -2150,7 +2151,10 @@ export function deployFunctions( // `@supabase/config` merges the matching `[remotes.*]` block over the base // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved // config already reflects any remote function/edge_runtime overrides. - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { projectRef }); + const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { + projectRef, + goViperCompat: dependencies.goViperCompat, + }); const deployConfig = loadedConfig?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( deployConfig?.edge_runtime.deno_version, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index e11a1c4736..710afdd972 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -127,6 +127,7 @@ export interface FunctionsServeDependencies { readonly debug: boolean; readonly networkId: Option.Option; readonly projectIdOverride: Option.Option; + readonly goViperCompat: boolean; } interface PlainServeAuthConfig { @@ -616,6 +617,7 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, + goViperCompat: boolean, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); const projectRef = Option.match(projectIdOverride, { @@ -632,28 +634,35 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const loadedConfig = yield* loadProjectConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { projectEnv }), + goViperCompat, }); const baseConfig = loadedConfig?.config ?? defaultProjectConfig; const auth = projectEnv === null ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig(yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth")); + : toPlainAuthConfig( + yield* resolveProjectSubtree(baseConfig.auth, projectEnv, "auth", { goViperCompat }), + ); const edgeRuntime = projectEnv === null ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) : toPlainEdgeRuntimeConfig( - yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime"), + yield* resolveProjectSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime", { + goViperCompat, + }), ); const apiPort = projectEnv === null ? baseConfig.api.port - : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api")).port; + : (yield* resolveProjectSubtree(baseConfig.api, projectEnv, "api", { goViperCompat })).port; const configDeclaredFunctions = projectEnv === null ? toPlainFunctionRecord(baseConfig.functions) : toPlainFunctionRecord( - yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions"), + yield* resolveProjectSubtree(baseConfig.functions, projectEnv, "functions", { + goViperCompat, + }), ); const configForManifest: ProjectConfig = { ...baseConfig, @@ -667,7 +676,9 @@ const resolveServeConfig = Effect.fnUntraced(function* ( projectEnv === null ? (baseConfig.project_id ?? "") : (reveal( - yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id"), + yield* resolveProjectValue(baseConfig.project_id ?? "", projectEnv, "project_id", { + goViperCompat, + }), ) ?? ""); const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); @@ -1296,6 +1307,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { const resolved = yield* resolveServeConfig( input.dependencies.projectRoot, input.dependencies.projectIdOverride, + input.dependencies.goViperCompat, ); const projectId = resolved.projectId; const containerId = localDockerId("edge_runtime", projectId); 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( diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index fcb059220d..4e7691d117 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -118,6 +118,15 @@ const provider = (providerConfig: { const defaultExternal = {}; +/** + * Go's deprecated `linkedin`/`slack` provider ids (`pkg/config/config.go:1418- + * 1423`) are intentionally NOT modeled here — only their `_oidc` replacements + * (`linkedin_oidc`, `slack_oidc`) are, matching Go's `(e external) validate()`, + * which unconditionally deletes the deprecated keys before anything decodes + * them. `io.ts`'s `normalizeDeprecatedExternalProviders` strips a config's + * `linkedin`/`slack` table (warning on stderr when it was `enabled`, same as + * Go) before this schema ever sees it. + */ export const external = Schema.Struct({ apple: provider({ id: "apple", @@ -185,8 +194,8 @@ export const external = Schema.Struct({ id: "x", name: "X", }), - slack: provider({ - id: "slack", + slack_oidc: provider({ + id: "slack_oidc", name: "Slack", }), spotify: provider({ diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index a7d1e82b75..ab04ebdbcc 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -29,3 +29,15 @@ export class DuplicateRemoteProjectIdError extends Data.TaggedError( )<{ readonly message: string; }> {} + +/** + * A `[remotes.]` block's `project_id` is not a valid 20-lowercase-letter + * project ref. Mirrors Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go: + * 558,996-1001`), which checks every remote's `project_id` against `refPattern` + * on every config load — regardless of whether that remote ends up selected — + * so this fails before Docker/API access, same as Go. `message` matches the Go + * string verbatim so callers can surface it without rewrapping. + */ +export class InvalidRemoteProjectIdError extends Data.TaggedError("InvalidRemoteProjectIdError")<{ + readonly message: string; +}> {} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e9a8eed763..77af2064ae 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,7 @@ export { ProjectConfigSchema, type ProjectConfig, type ProjectConfigJson } from "./base.ts"; export { DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, MissingProjectConfigValueError, ProjectConfigParseError, ProjectEnvParseError, @@ -30,6 +31,7 @@ export { type LoadProjectEnvironmentOptions, type ProjectEnvironment, type ResolvedProjectValue, + type ResolveProjectOptions, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, @@ -39,3 +41,4 @@ export { projectConfigStoreLayer } from "./project-config.layer.ts"; export { ProjectConfigStore } from "./project-config.service.ts"; export { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; +export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 85f9ca6201..2082485c77 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -1,7 +1,11 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { ProjectConfigSchema, type ProjectConfig } from "./base.ts"; -import { DuplicateRemoteProjectIdError, ProjectConfigParseError } from "./errors.ts"; +import { + DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, + ProjectConfigParseError, +} from "./errors.ts"; import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts"; import { findProjectPaths } from "./paths.ts"; import { loadProjectEnvironment, type ProjectEnvironment } from "./project.ts"; @@ -34,11 +38,18 @@ export interface LoadedProjectConfig { } /** - * When `projectRef` is set, the matching `[remotes.]` block (the one whose - * `project_id` equals it) is merged over the base config before decode, mirroring - * Go's `config.Load` with `Config.ProjectId` set - * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base config - * verbatim, so existing callers are unaffected. + * When `projectRef` is set, the matching `[remotes.]` block (the one + * whose `project_id` equals it) is merged over the base config before decode, + * mirroring Go's `config.Load` with `Config.ProjectId` set + * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base + * config verbatim (no merge), so existing callers are unaffected. Go's + * duplicate-`project_id`/project-ref-format checks across every + * `[remotes.*]` block (`config.go:594-602,996-1001`) run unconditionally on + * every config load in Go, not only when a caller ends up selecting a + * remote — but here they only run when {@link LoadProjectConfigOptions.goViperCompat} + * is `true`, regardless of whether `projectRef` is set, so non-Go-parity + * callers that never select a remote (and never opt into Go parity) aren't + * broken by an unrelated duplicate/malformed `[remotes.*]` block. */ export interface LoadProjectConfigOptions { readonly projectRef?: string; @@ -51,6 +62,37 @@ export interface LoadProjectConfigOptions { * so loading does not re-read those files or depend on `process.env` mutation. */ readonly projectEnv?: ProjectEnvironment; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; + /** + * Skip the `config.json`-over-`config.toml` preference below and only ever + * load `config.toml`. Go's `Config.Load`/`NewPathBuilder` + * (`apps/cli-go/pkg/config/utils.go:43-48`) has no concept of a JSON project + * config file — it always resolves `supabase/config.toml` and treats a + * missing file as defaults — so Go-parity callers (the legacy `status`/`stop` + * ports) must set this to avoid picking up a stray `config.json` that Go + * would never see. + */ + readonly tomlOnly?: boolean; + /** + * Opt into the Go/viper-parity decode+validation semantics this loader + * otherwise omits, so only the Go-parity legacy shell (and shared modules + * invoked exclusively by it) pays for them. Defaults to `false` = pre-PR-#5765 + * behavior, which `next/`, `packages/stack`, and the functions manifest rely + * on. When `true`, mirrors Go's `config.Load` exactly: + * - runs the unconditional duplicate-`project_id` and project-ref-format + * checks across every `[remotes.*]` block (`config.go:594-602,996-1001`), + * even when no `projectRef` is requested; + * - warns on stderr for deprecated `auth.external.{linkedin,slack}` blocks + * (`config.go:1418-1423`) — the block is stripped from the decoded config + * either way, since the schema ignores excess properties; + * - matches `env(...)` references case-agnostically (`^env\((.*)\)$`) + * rather than the strict SCREAMING_SNAKE_CASE form; + * - splits a comma-separated string into a `[]string`-typed field (Go's + * `mapstructure.StringToSliceHookFunc(",")`, `config.go:775-784`), not + * just an `env()`-substituted one. + */ + readonly goViperCompat?: boolean; } export interface SaveProjectConfigOptions { @@ -124,28 +166,20 @@ function withDbSeedDisabled(document: Record): Record]` override whose `project_id` matches `projectRef` - * to `document`, mirroring Go's `loadFromFile` remote resolution - * (`config.go:503-518`). Returns the merged document (with `remotes` stripped) and - * the matched remote name. - * - * Like Go, duplicate `project_id`s are detected across *all* `[remotes.*]` blocks — - * not just the ones matching `projectRef` — before the matching override is applied. - * A missing `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that + * Builds a `project_id -> "[remotes.]"` map across every `[remotes.*]` + * block, failing on the first duplicate. Mirrors Go's `loadFromFile` + * (`config.go:594-602`): that loop runs unconditionally on every config load, + * regardless of whether any remote's `project_id` ends up matching + * `Config.ProjectId`. Here, {@link applyRemoteOverride} only invokes this when + * `goViperCompat` is set, so it still runs even for callers that don't + * request a specific `projectRef` — but only under Go-parity mode. A missing + * `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that * both omit it collide on the empty key and fail just as in Go. */ -const applyRemoteOverride = Effect.fnUntraced(function* ( - document: Record, - projectRef: string, +const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( + remotes: Record, ) { - const remotes = document["remotes"]; - if (!isObject(remotes)) { - return { document, appliedRemote: undefined as string | undefined }; - } - // Build a project_id -> "[remotes.]" map over every remote, failing on the - // first duplicate, then resolve the single block matching projectRef. const idToName = new Map(); - let name: string | undefined; for (const [remoteName, remote] of Object.entries(remotes)) { const projectId = isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; @@ -156,17 +190,91 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( }); } idToName.set(projectId, `[remotes.${remoteName}]`); - if (projectId === projectRef) { - name = remoteName; + } +}); + +/** Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:558`): exactly 20 + * lowercase ASCII letters. */ +const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; + +/** + * Rejects the first `[remotes.*]` block whose `project_id` is not a valid + * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that + * loop runs unconditionally over every remote on every config load, not only + * the one that ends up selected/merged. Here, {@link applyRemoteOverride} only + * invokes this when `goViperCompat` is set. + * + * Unlike {@link checkDuplicateRemoteProjectIds}/the match below (which read + * viper's raw, pre-`LoadEnvHook` values — see {@link applyRemoteOverride}'s + * doc comment), `Config.Validate` runs entirely AFTER the struct decode + * (`config.go:882`), by which point `LoadEnvHook` has already resolved every + * `env(...)` reference (`config.go:749-753`). So this check must see the + * already-interpolated `project_id`, not the literal `env(REF)` form — an + * `[remotes.x] project_id = "env(REF)"` that resolves to a valid 20-letter ref + * passes here even though the raw string doesn't match the pattern itself. + */ +const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record) { + for (const [remoteName, remote] of Object.entries(remotes)) { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + if (!REMOTE_PROJECT_ID_PATTERN.test(projectId)) { + return yield* new InvalidRemoteProjectIdError({ + message: `Invalid config for remotes.${remoteName}.project_id. Must be like: abcdefghijklmnopqrst`, + }); } } +}); + +/** + * Applies the `[remotes.]` override whose `project_id` matches `projectRef` + * to `rawDocument`, mirroring Go's `loadFromFile` remote resolution + * (`config.go:503-518`). Returns the merged document (with `remotes` stripped, + * still pre-`env()`-interpolation — the caller re-interpolates the result) and + * the matched remote name. `projectRef` of `undefined` never matches any remote + * (including one that itself omits `project_id`, which reads as `""`) — callers + * that don't request a specific remote get the duplicate/format checks below + * without the merge, so the base document loads verbatim as before. + * + * `rawDocument`'s `remotes` block is the PRE-interpolation document: Go's + * duplicate-check/selection loop in `loadFromFile` reads directly off viper's + * raw config values (`v.GetString(fmt.Sprintf("remotes.%s.project_id", name))`, + * `config.go:596-610`) and only calls `c.load(v)` — which resolves `env(...)` + * via `LoadEnvHook` during the struct decode (`config.go:749-753`, + * `decode_hooks.go:13-26`) — afterward (`config.go:611`). So a + * `[remotes.prod] project_id = "env(REF)"` is matched/deduped against the + * LITERAL `env(REF)` string in Go, never against `REF`'s resolved value; this + * mirrors that exactly rather than matching post-interpolation, which would + * merge a remote Go itself would never select. `interpolatedRemotes` (Go's + * post-decode `c.Remotes`, mirrored here as the already-interpolated + * `remotes` subtree) is used only for {@link checkRemoteProjectIdFormat} — see + * its doc comment for why that check needs the resolved value instead. + */ +const applyRemoteOverride = Effect.fnUntraced(function* ( + rawDocument: Record, + interpolatedRemotes: Record | undefined, + projectRef: string | undefined, + goViperCompat: boolean, +) { + const remotes = rawDocument["remotes"]; + if (!isObject(remotes)) { + return { document: rawDocument, appliedRemote: undefined as string | undefined }; + } + if (goViperCompat) { + yield* checkDuplicateRemoteProjectIds(remotes); + yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); + } + const name = Object.entries(remotes).find(([, remote]) => { + const projectId = + isObject(remote) && typeof remote["project_id"] === "string" ? remote["project_id"] : ""; + return projectRef !== undefined && projectId === projectRef; + })?.[0]; if (name === undefined) { - return { document, appliedRemote: undefined as string | undefined }; + return { document: rawDocument, appliedRemote: undefined as string | undefined }; } const remoteSubtree = remotes[name]; let merged = isObject(remoteSubtree) - ? mergeRemoteSubtree(document, remoteSubtree) - : { ...document }; + ? mergeRemoteSubtree(rawDocument, remoteSubtree) + : { ...rawDocument }; if (!(isObject(remoteSubtree) && remoteSetsDbSeedEnabled(remoteSubtree))) { merged = withDbSeedDisabled(merged); } @@ -317,6 +425,78 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum return { document: normalized, deprecatedSections }; } +interface NormalizedExternalProvidersDocument { + readonly document: unknown; + /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled` — drives the WARN. */ + readonly deprecatedProviders: ReadonlyArray; +} + +const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; + +/** + * Go's `(e external) validate()` deprecated-provider handling + * (`apps/cli-go/pkg/config/config.go:1418-1423`): `linkedin`/`slack` are + * unconditionally deleted from `auth.external` before the required-field loop + * runs, so a bare `[auth.external.slack] enabled = true` with no + * `client_id`/`secret` loads fine in Go — a warning prints to stderr only + * when the deleted provider was `enabled`, never a hard failure. + * + * Unlike {@link normalizeDeprecatedSMTPSections}'s `[inbucket]` rename — which + * Go's own `normalizeDeprecatedSMTPConfig` runs BEFORE remote selection, over + * every `[remotes.*]` entry unconditionally (`config.go:594,614-640`) — Go's + * `external.validate()` runs from `Config.Validate()`, exactly ONCE on the + * final post-remote-merge struct (`config.go:882,1148`). A non-selected + * remote's own `auth.external.slack` block is never even looked at by Go. So + * this must run on the POST-merge document (`documentForDecode`, after + * `applyRemoteOverride`), not the pre-merge one: + * - the top-level `auth.external.{linkedin,slack}` is always stripped, and + * reported (for the caller to warn on) only when it was `enabled`, + * matching Go's single `external.validate()` call. + * - any `remotes.*.auth.external.{linkedin,slack}` still present (only + * possible when no remote matched `projectRef`, so `applyRemoteOverride` + * left `remotes` in place) is also stripped, but never reported — purely + * so `remoteProjectConfig`'s eager, whole-map schema decode + * (`packages/config/src/base.ts`) doesn't reject an unselected remote's + * deprecated block over a field Go itself never struct-decodes at all for + * a remote that isn't in effect. + */ +function normalizeDeprecatedExternalProviders( + document: unknown, +): NormalizedExternalProvidersDocument { + if (!isObject(document)) { + return { document, deprecatedProviders: [] }; + } + const normalized = { ...document }; + const deprecatedProviders: Array = []; + if (isObject(normalized.auth) && isObject(normalized.auth.external)) { + const external = { ...normalized.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + const provider = external[ext]; + if (provider === undefined) continue; + if (isObject(provider) && provider.enabled === true) { + deprecatedProviders.push(ext); + } + delete external[ext]; + } + normalized.auth = { ...normalized.auth, external }; + } + if (isObject(normalized.remotes)) { + normalized.remotes = Object.fromEntries( + Object.entries(normalized.remotes).map(([name, remote]) => { + if (!isObject(remote) || !isObject(remote.auth) || !isObject(remote.auth.external)) { + return [name, remote]; + } + const external = { ...remote.auth.external }; + for (const ext of DEPRECATED_EXTERNAL_PROVIDERS) { + delete external[ext]; + } + return [name, { ...remote, auth: { ...remote.auth, external } }]; + }), + ); + } + return { document: normalized, deprecatedProviders }; +} + function getSchemaRef(document: unknown): string | undefined { if (!isObject(document)) { return undefined; @@ -405,25 +585,78 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( (yield* loadProjectEnvironment({ cwd: projectRoot, baseEnv: process.env, + search: options?.search, })); - const interpolated = interpolateEnvReferencesAgainstSchema( - normalized, - projectEnv?.values ?? {}, - ProjectConfigSchema, - ); - - // Merge the matching `[remotes.*]` override over the base document before - // decode (Go's `loadFromFile` with `Config.ProjectId` set). Only requested - // when a `projectRef` is supplied, so other callers load the base verbatim. - let documentForDecode: unknown = interpolated; + const goViperCompat = options?.goViperCompat ?? false; + const interpolateDocument = (document: unknown): unknown => + interpolateEnvReferencesAgainstSchema(document, projectEnv?.values ?? {}, ProjectConfigSchema, { + goViperCompat, + }); + + // Interpolated once here purely to give `applyRemoteOverride`'s FORMAT check + // (not its match/merge — see that function's doc comment) the resolved + // `remotes.*.project_id`, matching Go's post-decode `Config.Validate`. + const interpolatedForValidation = interpolateDocument(normalized); + const interpolatedRemotes = + isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) + ? interpolatedForValidation["remotes"] + : undefined; + + // Merge the matching `[remotes.*]` override over the RAW (pre-`env()`- + // interpolation) document — Go's `loadFromFile` duplicate-check/selection + // loop runs on viper's raw string values, before `LoadEnvHook` ever resolves + // `env(...)` (`config.go:594-611`, `decode_hooks.go:13-26`); see + // `applyRemoteOverride`'s doc comment. The match/merge itself always runs + // (callers that don't request a `projectRef` just never match a remote, so + // the base document loads verbatim), but the duplicate-`project_id`/format + // checks only run when `goViperCompat` is set — see `applyRemoteOverride`. + let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; - if (options?.projectRef !== undefined && isObject(interpolated)) { - const resolved = yield* applyRemoteOverride(interpolated, options.projectRef); + if (isObject(normalized)) { + const resolved = yield* applyRemoteOverride( + normalized, + interpolatedRemotes, + options?.projectRef, + goViperCompat, + ); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; } - const config = yield* parseProjectConfig(documentForDecode, format, filePath); + // The merge above ran on the raw document, so any `env(...)` reference in + // the winning remote's subtree (or elsewhere in the base) still needs + // resolving before decode — mirrors Go's `LoadEnvHook` running on the + // post-merge viper store inside `c.load(v)`. When no remote matched, this + // recomputes the same substitutions `interpolatedForValidation` already + // made (documentForDecode is just `normalized` again) — a redundant walk on + // that path, but correctness on the match+`env()` path matters more than + // avoiding it. + documentForDecode = isObject(documentForDecode) + ? interpolateDocument(documentForDecode) + : documentForDecode; + + // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from + // the POST-remote-merge document, matching `external.validate()` running + // once on the final effective config (see `normalizeDeprecatedExternalProviders`). + const { document: normalizedForDecode, deprecatedProviders } = + normalizeDeprecatedExternalProviders(documentForDecode); + // Warn on stderr, matching Go's `external.validate()` (`config.go:1418-1423`). + // Go's own format string is a raw string literal ending in a literal + // backslash-n (raw string literals never process escapes, and `Fprintf` + // doesn't append a newline the way `Fprintln` does), so Go's actual stderr + // bytes have no real line break after this message — a library-internal + // artifact, not the parity-relevant part, same call already made for + // `LegacyInvalidPortEnvOverrideError` in the legacy shell. Not reproduced + // byte-for-byte; `Console.error` supplies a normal trailing newline instead. + if (goViperCompat) { + for (const ext of deprecatedProviders) { + yield* Console.error( + `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, + ); + } + } + + const config = yield* parseProjectConfig(normalizedForDecode, format, filePath); return { path: filePath, @@ -431,7 +664,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( config, schemaRef: getSchemaRef(document), ignoredPaths: [], - document: isObject(documentForDecode) ? documentForDecode : undefined, + document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, appliedRemote, } satisfies LoadedProjectConfig; }); @@ -441,7 +674,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( options?: LoadProjectConfigOptions, ) { const fs = yield* FileSystem.FileSystem; - const project = yield* findProjectPaths(cwd); + const project = yield* findProjectPaths(cwd, { search: options?.search }); if (project === null) { return null; @@ -454,7 +687,7 @@ export const loadProjectConfig = Effect.fnUntraced(function* ( ? project.configPath : project.configPath.replace(/config\.json$/, "config.toml"); - if (yield* fs.exists(jsonPath)) { + if (!options?.tomlOnly && (yield* fs.exists(jsonPath))) { const json = yield* loadProjectConfigFile(jsonPath, options); return { diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index d22d4eaf85..006dee8168 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -16,6 +16,7 @@ import { loadProjectConfig, loadProjectConfigFile, saveProjectConfig, + type LoadProjectConfigOptions, } from "./io.ts"; import { loadProjectConfig as loadProjectConfigFromNode } from "./node.ts"; import { projectConfigStoreLayer } from "./project-config.layer.ts"; @@ -323,6 +324,50 @@ major_version = 16 } }); + // Go's `NewPathBuilder`/`Config.Load` (`apps/cli-go/pkg/config/utils.go: + // 43-48`) only ever resolves `supabase/config.toml` — it has no concept of a + // JSON project config file. Go-parity callers (legacy `status`/`stop`) pass + // `tomlOnly: true` so a stray `config.json` never wins over `config.toml`. + test("loads TOML instead of JSON when tomlOnly is set, even if JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + const tomlPath = await runConfigEffect(configTomlPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + await writeFile( + tomlPath, + `project_id = "toml-ref" + +[db] +major_version = 16 +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded?.format).toBe("toml"); + expect(loaded?.config.project_id).toBe("toml-ref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("returns null when tomlOnly is set and only JSON exists", async () => { + const cwd = makeTempProject(); + const jsonPath = await runConfigEffect(configJsonPath(cwd)); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + expect(loaded).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads TOML when JSON is absent", async () => { const cwd = makeTempProject(); const tomlPath = await runConfigEffect(configTomlPath(cwd)); @@ -737,6 +782,128 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" } }); + test.each([ + ["1", true], + ["TRUE", true], + ["T", true], + ["True", true], + ["0", false], + ["f", false], + ["FALSE", false], + ] as const)( + "resolves env() on boolean fields using Go's strconv.ParseBool acceptance set (%s -> %s)", + async (envValue, expected) => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[analytics] +enabled = "env(SUPABASE_ANALYTICS_ENABLED)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), `SUPABASE_ANALYTICS_ENABLED=${envValue}\n`); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.analytics.enabled).toBe(expected); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + + test("splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", async () => { + // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires + // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain + // string value for a `[]string` field like `additional_redirect_urls` + // decodes fine in Go — not just via `env(...)`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("splits an env()-substituted comma-separated string into a slice", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "env(SUPABASE_REDIRECT_URLS)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_REDIRECT_URLS=http://a,http://b\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an empty string literal for a slice field decodes to an empty array", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.auth.additional_redirect_urls).toEqual([]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("an actual array value for a slice field is left untouched", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = ["http://a", "http://b"] +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { const cwd = makeTempProject(); @@ -758,6 +925,28 @@ jwt_secret = "env(MISSING_SECRET)" } }); + test("preserves env() literals on string fields when the var is set but empty (Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(MISSING_SECRET)" +`, + ); + await writeFile(join(cwd, "supabase", ".env"), "MISSING_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("fails to decode a numeric field when env var is unset", async () => { const cwd = makeTempProject(); @@ -816,6 +1005,163 @@ port = "env(SUPABASE_DB_PORT_TEST)" await rm(cwd, { recursive: true, force: true }); } }); + + // Regression coverage for the default-off (`goViperCompat` omitted) path — + // these pin pre-PR-#5765 behavior so `next/`, `packages/stack`, and the + // functions manifest (none of which pass `goViperCompat`) don't inherit the + // Go-parity legacy shell's stricter/wider semantics. + test("loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded).not.toBeNull(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not split a comma-separated string literal for an array field when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +additional_redirect_urls = "http://a,http://b" +`, + ); + + const exit = await Effect.runPromiseExit( + loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect((error.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not warn on a deprecated provider (but still strips it) when goViperCompat is omitted", async () => { + const cwd = makeTempProject(); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect("slack" in loaded!.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + } finally { + errorSpy.mockRestore(); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not resolve a lowercase-named env() reference when goViperCompat is omitted", async () => { + const previous = process.env.lowercase_ref_default_off_test; + process.env.lowercase_ref_default_off_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_off_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.config.project_id).toBe("env(lowercase_ref_default_off_test)"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_off_test; + } else { + process.env.lowercase_ref_default_off_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const previous = process.env.lowercase_ref_default_on_test; + process.env.lowercase_ref_default_on_test = "lowercase-ref-value"; + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + join(cwd, "supabase", "config.toml"), + `project_id = "env(lowercase_ref_default_on_test)"\n`, + ); + + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.project_id).toBe("lowercase-ref-value"); + } finally { + if (previous === undefined) { + delete process.env.lowercase_ref_default_on_test; + } else { + process.env.lowercase_ref_default_on_test = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); }); describe("config io [remotes.*] merge", () => { @@ -826,6 +1172,14 @@ describe("config io [remotes.*] merge", () => { return cwd; } + // Remote `project_id`s below are valid 20-lowercase-letter refs (Go's + // `refPattern`, `config.go:558`) — `Config.Validate` rejects every + // `[remotes.*].project_id` against that pattern unconditionally on every + // config load (`config.go:996-1001`), so test fixtures must satisfy it too, + // even for scenarios that don't care about the ref's specific value. + const PREVIEW_REF = "previewrefaaaaaaaaaa"; + const STAGING_REF = "stagingrefaaaaaaaaaa"; + const BASE_WITH_REMOTES = `project_id = "baseref" [api] @@ -837,13 +1191,13 @@ max_rows = 123 major_version = 15 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] schemas = ["remote_only"] max_rows = 999 [remotes.staging] -project_id = "stagingref" +project_id = "${STAGING_REF}" [remotes.staging.api] enabled = false `; @@ -851,10 +1205,10 @@ enabled = false test("merges the matching remote subtree over the base before decode", async () => { const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); // remote block's project_id overrides the base - expect(loaded!.config.project_id).toBe("previewref"); + expect(loaded!.config.project_id).toBe(PREVIEW_REF); // remote scalar wins expect(loaded!.config.api.max_rows).toBe(999); // array replaced wholesale (not element-merged) @@ -883,7 +1237,10 @@ enabled = false } }); - test("does not merge remotes when no projectRef is requested", async () => { + test("does not merge remotes when no projectRef is requested and none has an empty project_id", async () => { + // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for + // commands with no `--project-ref` flag), so this only stays unmerged + // because neither remote's `project_id` is empty. const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { const loaded = await runConfigEffect(loadProjectConfig(cwd)); @@ -895,6 +1252,42 @@ enabled = false } }); + test("rejects duplicate project_id across remotes even when no projectRef is requested", async () => { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // status/stop (internal/utils/flags/config_path.go:11) never bind a + // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, + // and it must still fail on a config-wide duplicate. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.a] +project_id = "dupref" + +[remotes.b] +project_id = "dupref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => + Effect.succeed(error.message), + ), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + // `goViperCompat` is required even though a `projectRef` is passed: the + // duplicate/format checks in `applyRemoteOverride` are gated solely on + // `goViperCompat`, not on whether a remote is being selected — the remote + // match/merge itself stays unconditional, but pre-PR-#5765 callers that + // pass a `projectRef` without opting into Go parity no longer get these + // checks for free. test("rejects duplicate project_id across remotes with Go's message", async () => { const cwd = await writeTomlProject(`project_id = "baseref" @@ -906,7 +1299,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "dupref" }).pipe( + loadProjectConfig(cwd, { projectRef: "dupref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -936,7 +1329,7 @@ project_id = "dupref" `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -964,7 +1357,7 @@ max_rows = 2 `); try { const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref" }).pipe( + loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message), ), @@ -977,16 +1370,41 @@ max_rows = 2 } }); + test("rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", async () => { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only the one + // that ends up selected — so this must fail closed before status/stop reach + // Docker, exactly like Go, even when the caller never selects a remote. + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.bad] +project_id = "not-a-ref" +`); + try { + const message = await Effect.runPromise( + loadProjectConfig(cwd, { goViperCompat: true }).pipe( + Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), + ), + ); + expect(message).toBe( + "Invalid config for remotes.bad.project_id. Must be like: abcdefghijklmnopqrst", + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("the merged document carries pointer sections introduced by the remote", async () => { const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.ssl_enforcement] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); // `legacyPresenceIn` reads `document` to detect optional pointer sections; // a remote-introduced `db.ssl_enforcement` must be present there. const db = loaded!.document?.db; @@ -1003,12 +1421,12 @@ enabled = true enabled = true [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = 5 `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(false); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1019,18 +1437,103 @@ max_rows = 5 const cwd = await writeTomlProject(`project_id = "baseref" [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.db.seed] enabled = true `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.db.seed.enabled).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); } }); + test("resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", async () => { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is + // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so + // `project_id = "env(project_id)"` resolves against a same-case env var + // in the Go CLI. This isn't specific to `project_id`; any string field + // goes through the same pre-decode walk. This case-agnostic matching is + // itself one of the four Go-viper-parity behaviors gated by + // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher + // wouldn't match this lowercase name at all. + const previous = process.env.project_id; + process.env.project_id = "lowercase-ref"; + const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + expect(loaded!.config.project_id).toBe("lowercase-ref"); + } finally { + if (previous === undefined) { + delete process.env.project_id; + } else { + process.env.project_id = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", async () => { + // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW + // string values (`config.go:596-610`) and only calls `c.load(v)` — which + // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, + // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` + // never matches a caller-supplied, already-resolved `REF`: Go compares the + // literal `env(REF)` string, not what it resolves to. + const previous = process.env.SUPABASE_REMOTE_ENV_REF_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[api] +max_rows = 1 + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_TEST)" +[remotes.preview.api] +max_rows = 999 +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.api.max_rows).toBe(1); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("validates a remote's env(REF) project_id format against its resolved value, not the literal", async () => { + // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the + // struct decode, by which point `LoadEnvHook` has already resolved + // `env(...)` — so it validates the RESOLVED project_id against the + // 20-lowercase-letter pattern, not the literal `env(REF)` string (which + // would never match the pattern itself). + const previous = process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = PREVIEW_REF; + const cwd = await writeTomlProject(`project_id = "baseref" + +[remotes.preview] +project_id = "env(SUPABASE_REMOTE_ENV_REF_FORMAT_TEST)" +`); + try { + const loaded = await runConfigEffect(loadProjectConfig(cwd)); + expect(loaded!.appliedRemote).toBeUndefined(); + expect(loaded!.config.project_id).toBe("baseref"); + } finally { + if (previous === undefined) { + delete process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; + } else { + process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = previous; + } + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolves env() references inside the matching remote before merge", async () => { const previous = process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = "777"; @@ -1040,12 +1543,12 @@ enabled = true max_rows = 1 [remotes.preview] -project_id = "previewref" +project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" `); try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "previewref" })); + const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.config.api.max_rows).toBe(777); } finally { if (previous === undefined) { @@ -1155,7 +1658,7 @@ port = 22222 `project_id = "abc123" [remotes.staging] -project_id = "stagingref" +project_id = "stagingrefaaaaaaaaaa" [remotes.staging.inbucket] enabled = true @@ -1190,3 +1693,124 @@ port = 54324 expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); }); }); + +describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { + let warnings: Array = []; + let errorSpy: ReturnType | undefined; + + function captureWarnings() { + warnings = []; + errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + } + + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); + + async function loadToml(contents: string, options?: LoadProjectConfigOptions) { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + try { + return await runConfigEffect(loadProjectConfigFile(path, options)); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("loads a bare [auth.external.slack] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = true +`, + { goViperCompat: true }, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(loaded.document).not.toHaveProperty("auth.external.slack"); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("loads a bare [auth.external.linkedin] block without required fields", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.linkedin] +enabled = true +`, + { goViperCompat: true }, + ); + + expect("linkedin" in loaded.config.auth.external).toBe(false); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', + ), + ), + ).toBe(true); + }); + + test("does not warn when the deprecated section is present but disabled", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack] +enabled = false +`, + ); + + expect("slack" in loaded.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("does not warn when only [auth.external.slack_oidc] is used", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[auth.external.slack_oidc] +enabled = true +client_id = "abc" +secret = "shh" +`, + ); + + expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" + +[remotes.staging.auth.external.slack] +enabled = true +`, + ); + + // Not requesting `projectRef` means no remote is selected, so `remotes` survives + // decode verbatim (minus the deprecated key) rather than being merged/dropped. + expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); +}); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index bd16819293..23a0c30056 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -1,11 +1,22 @@ import { Schema, SchemaAST } from "effect"; -export const ENV_PATTERN = "^env\\([A-Z_][A-Z0-9_]*\\)$"; -export const ENV_CAPTURE_REGEX = /^env\(([A-Z_][A-Z0-9_]*)\)$/; +// Go's `LoadEnvHook` matcher (`apps/cli-go/pkg/config/decode_hooks.go:11`) is +// `^env\((.*)\)$` — permissive on the captured name's case/content, and +// reused verbatim for secrets (`secret.go:99`) and the unset-var warning +// (`config.go:1195`). Matching that exactly (not an uppercase-only +// restriction) so e.g. `project_id = "env(project_id)"` substitutes the same +// way it does in the Go CLI. +export const ENV_PATTERN = "^env\\((.*)\\)$"; +export const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; +// Pre-PR-#5765 strict matcher: SCREAMING_SNAKE_CASE names only. Selected when +// `goViperCompat` is off so non-Go-parity surfaces (next/, packages/stack, the +// functions manifest) keep the narrower matching they had before PR #5765 +// widened env() resolution to Go's case-agnostic `^env\((.*)\)$`. +export const ENV_CAPTURE_REGEX_STRICT = /^env\(([A-Z_][A-Z0-9_]*)\)$/; const envRegex = new RegExp(ENV_PATTERN); -export function isEnvReference(value: string): boolean { - return envRegex.test(value); +export function isEnvReference(value: string, goViperCompat: boolean): boolean { + return (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).test(value); } interface EnvAnnotations extends Schema.Annotations.Documentation { @@ -52,12 +63,29 @@ export const secret = (annotations?: SecretAnnotations) => // and the value is still a string, coerce it. This mirrors Go's // mapstructure chain where `LoadEnvHook` returns a string and subsequent // hooks convert it to the target type. -// - Coercion is only attempted on strings produced by env() substitution. -// Pre-existing string literals at non-string paths are left untouched — -// they'll surface as schema errors at decode time with their original -// value, preserving error clarity. +// - Number/boolean coercion is only attempted on strings produced by env() +// substitution. Pre-existing string literals at non-string paths are left +// untouched — they'll surface as schema errors at decode time with their +// original value, preserving error clarity. +// - Array coercion is the one exception: if the schema at that path expects +// a homogeneous string array, ANY string leaf (substituted or a plain +// literal) is split on `,` — mirroring Go's `StringToSliceHookFunc(",")` +// (`apps/cli-go/pkg/config/config.go:775-784`), which is wired +// unconditionally into the decode hook chain regardless of where the +// string came from (e.g. `additional_redirect_urls = "http://a,http://b"` +// decodes fine in Go today, not just via `env(...)`). -type ExpectedType = "number" | "boolean" | "string" | "unknown"; +type ExpectedType = "number" | "boolean" | "string" | "array" | "unknown"; + +// Go decodes an env()-substituted boolean via mapstructure's weakly-typed +// `decodeBool`, which runs `strconv.ParseBool` on the string — a wider +// acceptance set than the literal `"true"`/`"false"` this module used to +// require. Mirrors `legacyParseGoBool`'s `GO_BOOL_TRUE`/`GO_BOOL_FALSE` +// (`apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts:615-616`); +// duplicated here (not imported) so `packages/config` doesn't depend on +// `apps/cli`. +const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); // Unwrap Suspend (lazy AST refs from recursive schemas). Other transformation // wrappers expose the target type via `.ast` directly, so no additional @@ -69,6 +97,20 @@ function unwrapAst(ast: SchemaAST.AST): SchemaAST.AST { return ast; } +// A homogeneous `Schema.Array(Schema.String)` compiles to an `Arrays` AST +// node with no fixed tuple `elements` and a single `rest` spread type. Only +// this shape (not a fixed string tuple, and not a mixed-type array) is +// eligible for Go's `StringToSliceHookFunc(",")` coercion below — mirroring +// that Go itself only wires the hook for `[]string`-kind targets +// (`apps/cli-go/pkg/config/config.go:775-784`), not fixed-arity tuples. +function isHomogeneousStringArray(node: SchemaAST.AST): boolean { + if (node._tag !== "Arrays" || node.elements.length !== 0 || node.rest.length !== 1) { + return false; + } + const spread = node.rest[0]; + return spread !== undefined && unwrapAst(spread)._tag === "String"; +} + function leafExpectedType(ast: SchemaAST.AST): ExpectedType { const node = unwrapAst(ast); switch (node._tag) { @@ -78,6 +120,8 @@ function leafExpectedType(ast: SchemaAST.AST): ExpectedType { return "boolean"; case "String": return "string"; + case "Arrays": + return isHomogeneousStringArray(node) ? "array" : "unknown"; case "Union": { // Walk Union branches in declared order; first concrete primitive wins. // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks @@ -156,23 +200,39 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { return value; } if (expected === "boolean") { - if (value === "true") return true; - if (value === "false") return false; + if (GO_BOOL_TRUE.has(value)) return true; + if (GO_BOOL_FALSE.has(value)) return false; return value; } + if (expected === "array") { + // Go's `mapstructure.StringToSliceHookFunc(",")` (wired in + // `apps/cli-go/pkg/config/config.go:775-784`): an empty string decodes to + // an empty slice, otherwise the string is split on the separator with no + // further trimming of the resulting elements. + return value === "" ? [] : value.split(","); + } return value; } -function substituteEnvLeaf(value: string, env: Readonly>): string { - const match = ENV_CAPTURE_REGEX.exec(value); +function substituteEnvLeaf( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); if (match === null) { return value; } const envName = match[1]; - if (envName === undefined || !Object.prototype.hasOwnProperty.call(env, envName)) { + const resolved = envName === undefined ? undefined : env[envName]; + // Go's LoadEnvHook only substitutes when the env var is non-empty + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a + // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the + // `env(KEY)` literal exactly like an unset key, rather than substituting "". + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function isDeferredEnvField(ast: SchemaAST.AST): boolean { @@ -196,11 +256,12 @@ function walk( document: unknown, env: Readonly>, ast: SchemaAST.AST | null, + goViperCompat: boolean, ): unknown { if (Array.isArray(document)) { return document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); - return walk(item, env, child); + return walk(item, env, child, goViperCompat); }); } @@ -208,7 +269,7 @@ function walk( const result: Record = {}; for (const [key, value] of Object.entries(document)) { const child = ast === null ? null : descendAst(ast, key); - result[key] = walk(value, env, child); + result[key] = walk(value, env, child, goViperCompat); } return result; } @@ -220,18 +281,37 @@ function walk( if (ast !== null && isDeferredEnvField(ast)) { return document; } + + const substituted = substituteEnvLeaf(document, env, goViperCompat); + const expected = ast === null ? "unknown" : leafExpectedType(ast); + + // Go's `StringToSliceHookFunc(",")` (`apps/cli-go/pkg/config/config.go: + // 775-784`) is wired unconditionally into `v.UnmarshalExact`'s decode + // hook chain, so it splits ANY string being decoded into a `[]string` + // field — a plain TOML literal (`additional_redirect_urls = "a,b"`) just + // as much as an `env()`-substituted one. Unlike the number/boolean + // coercion below (scoped to substituted values only, since TOML already + // decodes literal numbers/booleans to their native type), array coercion + // must also apply to literal strings that never went through + // `substituteEnvLeaf`. Gated by `goViperCompat`: when off, the string is + // left unsplit — literal and substituted alike — so an array-typed field + // fed a string fails decode instead of silently coercing, matching + // pre-PR-#5765 behavior. + if (expected === "array") { + return goViperCompat ? coerceLeaf(substituted, expected) : substituted; + } + // Substitute env() then coerce based on the schema's expected type at this // path. Only the substituted form is fed to coercion — literal strings at // non-string paths are left untouched so the decoder can report them with // their original value. - const substituted = substituteEnvLeaf(document, env); if (substituted === document) { return document; } if (ast === null) { return substituted; } - return coerceLeaf(substituted, leafExpectedType(ast)); + return coerceLeaf(substituted, expected); } return document; @@ -242,8 +322,11 @@ function walk( * * Walks the raw parsed document and the schema AST in parallel. For every * string leaf matching `env(VAR)`: - * 1. Substitutes `env[VAR]` if set, else preserves the literal verbatim - * (Go-parity with `apps/cli-go/pkg/config/decode_hooks.go:14-21`). + * 1. Substitutes `env[VAR]` if set AND non-empty, else preserves the + * literal verbatim (Go-parity with + * `apps/cli-go/pkg/config/decode_hooks.go:14-21`, which gates on + * `len(env) > 0` — a set-but-empty var, e.g. a dotenv `KEY=` line, + * leaves the `env(KEY)` literal untouched just like an unset one). * 2. If the schema at that path expects Number or Boolean, coerces the * substituted string to the expected primitive — mirroring Go's * mapstructure chain where `LoadEnvHook` returns a string that the next @@ -255,6 +338,7 @@ export function interpolateEnvReferencesAgainstSchema( document: unknown, env: Readonly>, schema: { readonly ast: SchemaAST.AST }, + options?: { readonly goViperCompat?: boolean }, ): unknown { - return walk(document, env, schema.ast); + return walk(document, env, schema.ast, options?.goViperCompat ?? false); } diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index e41017793d..bf29f44dcc 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -31,10 +31,41 @@ const findConfigInRoot = Effect.fnUntraced(function* (root: string) { } satisfies ProjectPaths; }); -export const findProjectPaths = Effect.fnUntraced(function* (cwd: string) { +export interface FindProjectPathsOptions { + /** + * When `false`, only `cwd` itself is checked for `supabase/config.{json,toml}` — + * no ancestor climb. Go's own resolution never searches twice: an explicit + * `--workdir`/`SUPABASE_WORKDIR` is used exactly as given (`ChangeWorkDir`, + * `apps/cli-go/internal/utils/misc.go:231-247`), and once `os.Chdir`'d there, + * `config.toml` is read as a plain relative path with no further ancestor + * search (`NewPathBuilder`, `pkg/config/utils.go:43-48`). Ancestor climbing in + * Go only ever happens once, as the *default* when workdir is unset + * (`getProjectRoot`, `internal/utils/misc.go:209-224`). + * + * Callers that already hold an authoritative, Go-equivalent project root + * (e.g. the legacy `stop`/`status` ports' `cliConfig.workdir`, which mirrors + * `ChangeWorkDir`'s own explicit-vs-default resolution) should pass `false` + * here to avoid a second, un-Go-like ancestor search that could otherwise + * pick up an unrelated ancestor project's config. + * + * Defaults to `true` (the original ancestor-search behavior), so existing + * callers are unaffected. + */ + readonly search?: boolean; +} + +export const findProjectPaths = Effect.fnUntraced(function* ( + cwd: string, + options?: FindProjectPathsOptions, +) { const path = yield* Path.Path; - let current = path.resolve(cwd); + const start = path.resolve(cwd); + + if (options?.search === false) { + return yield* findConfigInRoot(start); + } + let current = start; while (true) { const match = yield* findConfigInRoot(current); diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 68953f2b1b..daa9cc0e08 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -1,10 +1,9 @@ import { Effect, FileSystem, Redacted } from "effect"; import { ProjectConfigSchema } from "./base.ts"; import { ProjectEnvParseError } from "./errors.ts"; -import { ENV_CAPTURE_REGEX, isEnvReference } from "./lib/env.ts"; +import { ENV_CAPTURE_REGEX, ENV_CAPTURE_REGEX_STRICT, isEnvReference } from "./lib/env.ts"; import { findProjectPaths, type ProjectPaths } from "./paths.ts"; -const envReferencePattern = ENV_CAPTURE_REGEX; const dotEnvLinePattern = /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/; @@ -45,6 +44,41 @@ function normalizeAmbientEnv( return values; } +// Detects a line of the form `KEY=...` (or `KEY: ...`) whose +// quoted value does NOT close on that same physical line — the start of a +// godotenv-style multiline quoted value (e.g. a PEM block). Returns the quote +// character and the index of the opening quote within `line`, or `null` if +// the line doesn't open an unterminated quote (either no quote at all, or one +// that already closes on this line). +const dotEnvValueOpenerPattern = /^\s*(?:export\s+)?[\w.-]+(?:\s*=\s*?|:\s+?)(['"`])/; + +function findUnescapedQuoteIndex(text: string, quote: string, from: number): number { + for (let i = from; i < text.length; i += 1) { + if (text[i] === quote && text[i - 1] !== "\\") { + return i; + } + } + return -1; +} + +function detectOpenQuoteStart(line: string): { quote: string; openIndex: number } | null { + const openerMatch = dotEnvValueOpenerPattern.exec(line); + if (openerMatch === null) { + return null; + } + const quote = openerMatch[1]; + if (quote === undefined) { + return null; + } + const openIndex = openerMatch[0].length - 1; + if (findUnescapedQuoteIndex(line, quote, openIndex + 1) !== -1) { + // Already closes on this same line — this isn't the multiline case, so + // whatever made the outer match fail is a genuine parse error. + return null; + } + return { quote, openIndex }; +} + function parseDotEnvValue(rawValue: string): string { let value = rawValue.trim(); const maybeQuote = value[0]; @@ -78,7 +112,40 @@ function parseDotEnv( continue; } - const match = dotEnvLinePattern.exec(line); + let candidate = line; + let consumedThrough = index; + + // Check for an unterminated quote BEFORE attempting the single-line + // match: `dotEnvLinePattern`'s value alternatives fall back to an + // unquoted match (`[^#\r\n]+`) when none of the quoted alternatives + // close on this line, which would otherwise "succeed" with a truncated, + // still-quote-prefixed value instead of signaling a multiline value — + // masking the real bug rather than triggering accumulation. This is a + // godotenv-style quoted value spanning multiple physical lines (e.g. a + // PEM block); Go's `loadNestedEnv` parses this fine (`godotenv@v1.5.1`'s + // cursor-based scanner never splits into lines up front; see + // `legacy-dotenv.ts` for the Go-compatible reference implementation used + // elsewhere in this repo). Accumulate subsequent lines until the opened + // quote closes (or EOF), then match the same per-line pattern against + // the joined multiline chunk — its quoted-value alternatives use + // negated character classes (`[^"]` etc.), which already match embedded + // newlines once given the full span. + const opener = detectOpenQuoteStart(line); + if (opener !== null) { + for (let next = index + 1; next < lines.length; next += 1) { + const nextLine = lines[next]; + if (nextLine === undefined) { + continue; + } + candidate += "\n" + nextLine; + consumedThrough = next; + if (findUnescapedQuoteIndex(candidate, opener.quote, opener.openIndex + 1) !== -1) { + break; + } + } + } + + const match = dotEnvLinePattern.exec(candidate); if (match === null) { return yield* Effect.fail(new ProjectEnvParseError({ path, line: index + 1 })); @@ -92,6 +159,7 @@ function parseDotEnv( } values[key] = parseDotEnvValue(rawValue); + index = consumedThrough; } return values; @@ -113,13 +181,36 @@ function applySource( export interface LoadProjectEnvironmentOptions { readonly cwd: string; readonly baseEnv?: Readonly>; + /** See {@link FindProjectPathsOptions.search}. */ + readonly search?: boolean; + /** + * Skip reading/parsing `paths.envLocalPath` (`supabase/.env.local`) + * entirely. Mirrors Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/ + * config.go:1243-1250`), which omits `.env.local` from its candidate + * filename list whenever `SUPABASE_ENV=test` — so a malformed or + * intentionally non-test `.env.local` is invisible to Go in that mode and + * must not fail config loading here either. Defaults to `false` so + * existing callers that don't have a `SUPABASE_ENV` gate of their own + * (`next/`, `secrets set`) are unaffected. + */ + readonly skipEnvLocal?: boolean; +} + +export interface ResolveProjectOptions { + /** + * Opt into Go/viper-parity `env()` matching (case-agnostic + * `^env\((.*)\)$`). Defaults to `false`, which uses the pre-PR-#5765 strict + * SCREAMING_SNAKE_CASE matcher (`ENV_CAPTURE_REGEX_STRICT`). Only the + * Go-parity legacy shell sets this to `true`. + */ + readonly goViperCompat?: boolean; } export const loadProjectEnvironment = Effect.fnUntraced(function* ( options: LoadProjectEnvironmentOptions, ) { const fs = yield* FileSystem.FileSystem; - const paths = yield* findProjectPaths(options.cwd); + const paths = yield* findProjectPaths(options.cwd, { search: options.search }); if (paths === null) { return null; @@ -136,7 +227,7 @@ export const loadProjectEnvironment = Effect.fnUntraced(function* ( loadedPaths.push(paths.envPath); } - if (yield* fs.exists(paths.envLocalPath)) { + if (!options.skipEnvLocal && (yield* fs.exists(paths.envLocalPath))) { const contents = yield* fs.readFileString(paths.envLocalPath); const parsed = yield* parseDotEnv(paths.envLocalPath, contents); applySource(values, sources, parsed, ".env.local"); @@ -216,21 +307,33 @@ function isSecretPath(path: ReadonlyArray): boolean { return secretPathPatterns.some((pattern) => matchesPathPattern(pattern, path)); } -function interpolateLeafValue(value: string, env: Readonly>): string { - const match = envReferencePattern.exec(value); +function interpolateLeafValue( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); const envName = match?.[1]; if (envName === undefined) { return value; } - // Preserve the literal `env(VAR)` verbatim when VAR is unset. Matches Go's - // `apps/cli-go/pkg/config/decode_hooks.go:14-21` (LoadEnvHook). - if (!Object.prototype.hasOwnProperty.call(env, envName)) { + const resolved = env[envName]; + // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but + // empty (e.g. a dotenv `KEY=` line). Matches Go's `LoadEnvHook` + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), which + // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in + // `lib/env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. + // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets + // redacted by `redactValue` as a real value instead of skipped as an + // unresolved literal, and `secrets set` uploads a blank secret Go would + // never send. + if (resolved === undefined || resolved === "") { return value; } - return env[envName] ?? value; + return resolved; } function toPathSegments(path: string): ReadonlyArray { @@ -241,44 +344,48 @@ function toPathSegments(path: string): ReadonlyArray { return path.split(".").filter((segment) => segment.length > 0); } -function interpolateValue(value: unknown, env: Readonly>): unknown { +function interpolateValue( + value: unknown, + env: Readonly>, + goViperCompat: boolean, +): unknown { if (Array.isArray(value)) { - return value.map((item) => interpolateValue(item, env)); + return value.map((item) => interpolateValue(item, env, goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env); + result[key] = interpolateValue(child, env, goViperCompat); } return result; } if (typeof value === "string") { - return interpolateLeafValue(value, env); + return interpolateLeafValue(value, env, goViperCompat); } return value; } -function redactValue(value: unknown, path: ReadonlyArray = []): unknown { +function redactValue(value: unknown, path: ReadonlyArray, goViperCompat: boolean): unknown { if (Array.isArray(value)) { - return value.map((item, index) => redactValue(item, [...path, String(index)])); + return value.map((item, index) => redactValue(item, [...path, String(index)], goViperCompat)); } if (typeof value === "object" && value !== null) { const result: Record = {}; for (const [key, child] of Object.entries(value)) { - result[key] = redactValue(child, [...path, key]); + result[key] = redactValue(child, [...path, key], goViperCompat); } return result; } - if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value)) { + if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, goViperCompat)) { return Redacted.make(value, { label: path.join(".") }); } @@ -289,15 +396,17 @@ function resolveProjectValueAtPath( value: unknown, projectEnv: ProjectEnvironment, path: ReadonlyArray, + goViperCompat: boolean, ): unknown { - const interpolated = interpolateValue(value, projectEnv.values); - return redactValue(interpolated, path); + const interpolated = interpolateValue(value, projectEnv.values, goViperCompat); + return redactValue(interpolated, path, goViperCompat); } export function resolveProjectValue( value: T, projectEnv: ProjectEnvironment, configPath: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -305,6 +414,7 @@ export function resolveProjectValue( value, projectEnv, toPathSegments(configPath), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } @@ -313,6 +423,7 @@ export function resolveProjectSubtree( value: T, projectEnv: ProjectEnvironment, pathPrefix: string, + options?: ResolveProjectOptions, ): Effect.Effect> { return Effect.sync( () => @@ -320,6 +431,7 @@ export function resolveProjectSubtree( value, projectEnv, toPathSegments(pathPrefix), + options?.goViperCompat ?? false, ) as ResolvedProjectValue, ); } diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index de22a465fe..9f29094bc2 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, FileSystem, Path, Redacted } from "effect"; import { findProjectRootFor, loadProjectEnvironmentFor } from "./bun.ts"; -import { ProjectConfigParseError } from "./errors.ts"; +import { ProjectConfigParseError, ProjectEnvParseError } from "./errors.ts"; import { findProjectPaths, loadProjectConfig, @@ -50,6 +50,41 @@ describe("project discovery and lazy env resolution", () => { } }); + test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { + // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-247`): + // an explicit workdir is used exactly as given, with no ancestor climb — + // callers that already hold a Go-equivalent project root (e.g. the legacy + // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid + // picking up an unrelated ancestor project. + const cwd = makeTempProject(); + const repoRoot = join(cwd, "repo"); + const packageRoot = join(repoRoot, "apps", "web"); + const nestedCwd = join(packageRoot, "src", "components"); + + try { + await mkdir(join(repoRoot, "supabase"), { recursive: true }); + await mkdir(nestedCwd, { recursive: true }); + await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); + + // nestedCwd has no supabase/ of its own; only an ancestor (repoRoot) does. + const searched = await runConfigEffect(findProjectPaths(nestedCwd)); + expect(searched?.projectRoot).toBe(repoRoot); + + const unsearched = await runConfigEffect(findProjectPaths(nestedCwd, { search: false })); + expect(unsearched).toBeNull(); + + const configAtRepoRoot = await runConfigEffect(findProjectPaths(repoRoot, { search: false })); + expect(configAtRepoRoot?.projectRoot).toBe(repoRoot); + + expect(await runConfigEffect(loadProjectConfig(nestedCwd, { search: false }))).toBeNull(); + expect( + await runConfigEffect(loadProjectEnvironment({ cwd: nestedCwd, search: false })), + ).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads env from the discovered supabase directory with the right precedence", async () => { const cwd = makeTempProject(); const repoRoot = join(cwd, "repo"); @@ -107,6 +142,105 @@ describe("project discovery and lazy env resolution", () => { } }); + test("parses a multiline double-quoted .env value (godotenv/Go parity)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + [ + 'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----', + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + '-----END RSA PRIVATE KEY-----"', + "OTHER=value", + "", + ].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.PRIVATE_KEY).toBe( + [ + "-----BEGIN RSA PRIVATE KEY-----", + "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", + "-----END RSA PRIVATE KEY-----", + ].join("\n"), + ); + expect(projectEnv?.values.OTHER).toBe("value"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("parses a multiline single-quoted .env value followed by a trailing comment", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile( + join(cwd, "supabase", ".env"), + ["MULTI='line one", "line two' # trailing comment", "AFTER=ok", ""].join("\n"), + ); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.MULTI).toBe(["line one", "line two"].join("\n")); + expect(projectEnv?.values.AFTER).toBe("ok"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("still fails a genuinely malformed .env line (not a multiline quote)", async () => { + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "!!!not-a-valid-line\n"); + + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", async () => { + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits + // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, + // so a malformed `.env.local` is invisible to Go in that mode. Callers that + // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. + const cwd = makeTempProject(); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); + await writeFile(join(cwd, "supabase", ".env"), "FROM_ENV=1\n"); + // Malformed — would normally throw ProjectEnvParseError. + await writeFile(join(cwd, "supabase", ".env.local"), "!!!not-a-valid-line\n"); + + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd, skipEnvLocal: true })); + + expect(projectEnv).not.toBeNull(); + expect(projectEnv?.values.FROM_ENV).toBe("1"); + expect(projectEnv?.loadedPaths).toEqual([join(cwd, "supabase", ".env")]); + + // Without the flag, the same malformed file still fails as before. + await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( + ProjectEnvParseError, + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -214,6 +348,9 @@ jwt_secret = "env(AUTH_JWT_SECRET)" [edge_runtime.secrets] api_key = "env(EDGE_API_KEY)" +[remotes.preview] +project_id = "previewrefaaaaaaaaaa" + [remotes.preview.auth] jwt_secret = "env(PREVIEW_JWT_SECRET)" `, @@ -282,6 +419,44 @@ jwt_secret = "env(MISSING_SECRET)" } }); + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:19-24`) only + // substitutes a non-empty env var (`len(env) > 0`) — a present-but-empty + // dotenv line (`EMPTY_SECRET=`) is treated the same as an unset var, so the + // literal `env(...)` reference is preserved rather than resolved to `""`. + test("resolveProjectValue preserves env() literal when the env var is present but empty (Go parity)", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[edge_runtime.secrets] +foo = "env(EMPTY_SECRET)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "EMPTY_SECRET=\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue( + loaded!.config.edge_runtime.secrets!.foo, + projectEnv!, + "edge_runtime.secrets.foo", + ), + ); + + expect(Redacted.isRedacted(resolved)).toBe(false); + expect(resolved).toBe("env(EMPTY_SECRET)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("resolveProjectSubtree preserves env() literals nested inside the selected subtree", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -334,4 +509,75 @@ account_sid = "AC123" await rm(cwd, { recursive: true, force: true }); } }); + + // Pins the pre-PR-#5765 strict SCREAMING_SNAKE_CASE `env()` matcher as the + // default for `resolveProjectValue`/`resolveProjectSubtree`, since `next/` + // and `packages/stack` call these without ever passing `goViperCompat`. + test("resolveProjectValue does not resolve a lowercase-named env() reference by default", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("env(lowercase_secret)"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("resolveProjectValue resolves a lowercase-named env() reference when goViperCompat is true", async () => { + const cwd = makeTempProject(); + const projectRoot = join(cwd, "repo"); + + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123" + +[auth] +jwt_secret = "env(lowercase_secret)" +`, + ); + await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); + + const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); + + const resolved = await runConfigEffect( + resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret", { + goViperCompat: true, + }), + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (!Redacted.isRedacted(resolved)) { + throw new Error("Expected auth.jwt_secret to be redacted."); + } + expect(Redacted.value(resolved)).toBe("super-secret"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); });