diff --git a/.oxlintrc.json b/.oxlintrc.json index bd131f3c2d..687e5608a6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -34,6 +34,40 @@ "typescript/no-base-to-string": "off" } }, + { + // Ambient env reads go through `CliConfigValues` (registry names) or the shared env-option + // helpers, so a stray `process.env` can't bypass the flag > shell > .env > config order. + "files": ["apps/cli/src/**"], + "excludeFiles": [ + "apps/cli/src/**/*.test.ts", + "apps/cli/src/shared/config/cli-config-provider.layer.ts", + "apps/cli/src/shared/config/cli-config-env.ts", + // Standalone node shim built with `bun build --target node`; it cannot import effect. + "apps/cli/src/shared/cli/bin.ts", + // Templates that run inside the user's deployed container, not in the CLI process. + "apps/cli/src/shared/compute/stacks/**" + ], + "rules": { + "no-restricted-properties": [ + "error", + { + "object": "process", + "property": "env", + "message": "Read env through CliConfigValues or the shared env helpers, not process.env." + }, + { + "object": "Bun", + "property": "env", + "message": "Read env through CliConfigValues or the shared env helpers, not Bun.env." + }, + { + "object": "globalThis", + "property": "process", + "message": "Use the bare `process` global; reading env through globalThis.process bypasses CliConfigValues." + } + ] + } + }, { // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers` // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at @@ -65,6 +99,108 @@ ] } }, + { + // Env must not be imported past `CliConfigValues`: not `env` from node:process/bun, and + // `ambientEnvironment` only in the audited files of the next override. This rule replaces the + // repo-wide one for these files, so the namespace pattern is repeated; keep the two in sync. + "files": ["apps/cli/src/**"], + "excludeFiles": [ + "apps/cli/src/**/*.test.ts", + "apps/cli/src/shared/config/cli-config-provider.layer.ts", + "apps/cli/src/shared/config/cli-config-env.ts", + "apps/cli/src/shared/cli/bin.ts", + "apps/cli/src/shared/compute/stacks/**", + "apps/cli/src/cli/complete.ts", + "apps/cli/src/command-internal/colors.ts", + "apps/cli/src/command-internal/db-config.parse.ts", + "apps/cli/src/command-internal/hostname.ts", + "apps/cli/src/command-internal/pgpass.ts", + "apps/cli/src/commands/login/login-claude-hint.ts", + "apps/cli/src/commands/start/lib/env-or-default.ts", + "apps/cli/src/commands/start/services/vector.service.ts", + "apps/cli/src/shared/functions/deploy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not node:process env." + }, + { + "name": "process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not process env." + }, + { + "name": "bun", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not Bun's env export." + } + ], + "patterns": [ + { + "group": ["**/cli-config-provider.layer.ts"], + "importNames": ["ambientEnvironment"], + "message": "Read env through CliConfigValues; only the audited non-registry readers may import ambientEnvironment." + }, + { + "group": ["**/namespace/**"], + "message": "Namespace internals are private; consume them through StackNamespace.ts." + } + ] + } + ] + } + }, + { + // These read terminal, libpq, docker and proxy variables, never a config registry name, so + // they may import `ambientEnvironment`; the rest of the override above still applies. + "files": [ + "apps/cli/src/cli/complete.ts", + "apps/cli/src/command-internal/colors.ts", + "apps/cli/src/command-internal/db-config.parse.ts", + "apps/cli/src/command-internal/hostname.ts", + "apps/cli/src/command-internal/pgpass.ts", + "apps/cli/src/commands/login/login-claude-hint.ts", + "apps/cli/src/commands/start/lib/env-or-default.ts", + "apps/cli/src/commands/start/services/vector.service.ts", + "apps/cli/src/shared/functions/deploy.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not node:process env." + }, + { + "name": "process", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not process env." + }, + { + "name": "bun", + "importNames": ["env"], + "message": "Read env through CliConfigValues or the shared env helpers, not Bun's env export." + } + ], + "patterns": [ + { + "group": ["**/namespace/**"], + "message": "Namespace internals are private; consume them through StackNamespace.ts." + } + ] + } + ] + } + }, { // Default for the whole stack package (`src/**` and `tests/**`): on top of the repo-wide // namespace restriction above, raw `node:fs`/`node:sqlite` are confined to namespace drivers diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 3b2e6251df..5c073ff68d 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -82,10 +82,66 @@ Every applicable command must preserve these invariants: `--output-format`, as established by `config diff` and `config pull`. 7. Preserve telemetry names, timing, identity, and payloads; see [Telemetry](#telemetry). +## Config values + +Every config value resolves in one order for every command: flag > shell env > project `.env*` +(`supabase/` then the project root, by `SUPABASE_ENV`) > `config.toml` (a matched `[remotes.*]` +block over the base document) > default. Do not reorder it or special-case it per command. + +**Read a value** + +- Load once per command and pass the result down: `const configValues = yield* CliConfigValues`, + then `const resolvedConfig = yield* configValues.load({ workdir, projectRef })`. Do not reload a + resolved config a caller already holds. +- `resolvedConfig.get(CliConfigKeys.)` returns `{ value, origin }`; `origin.tier` names the + tier that won. +- For the whole document: `loaded` (what the project declares, every winner written in, no + defaults), `materialized.config` (`loaded` plus defaults and normalizers, what commands act on) + or `fileDeclared` (the config file alone, no flag or env overlay, for `config diff`/`pull`). + `envValues(names)` resolves `env(NAME)` references, shell before project `.env*`. +- Wrap writes that change config or `.temp` in `configValues.writeThrough(...)` so the memoised + resolved config is dropped. + +**Add a flag that sets a config value** + +1. List the name in `CLI_CONFIG_FLAGS` (`config/cli-config-key-annotations.ts`). +2. Declare it with `CliConfigKeys..flag({ name, description })` in the command config, never + a raw `Flag.*`. +3. Pipe the command config through `withCliConfigFlags` and provide `cliConfigValuesLayer` with + `Command.provide`. Read the value with `get`; never merge a flag into a value by hand. + +**Add a config key or env var** + +Add the field to `CliConfigSchema`; the registry key and its `SUPABASE_` env name +follow. Touch `config/cli-config-key-annotations.ts` only for a section gate, codec override, +secret or exclusion. Never read a `SUPABASE_*` name anywhere else. + +**Variables that are not config keys** + +Names outside the registry (libpq `PG*`, Docker, proxy, `SUPABASE_YES`) come from +`resolvedConfig.projectEnvValues` (project `.env*` only; a shell-set name is not in it) or +`ambientEnvironment()` (the live process env). Importing `ambientEnvironment` is limited by +`oxlint` to the audited files listed in `.oxlintrc.json`. Never read a registry name through +either, and never read `process.env`, `Bun.env` or `globalThis.process` directly. + +**What fails the build** + +`code-structure.unit.test.ts` (registry env names as literals, direct env reads, a raw `Flag.*` for +a registry flag, `loadCliConfig`, `resolveCliConfigSubtree` or `loadCliProjectEnvironment` outside +`config/cli-config-*.ts` and `shared/config/cli-config-*.ts`), `oxlint` (`process.env`, `Bun.env`, +`ambientEnvironment` imports), `cli-config-contract.unit.test.ts` and +`cli-config-flag-ownership.unit.test.ts` (every registry key and `CLI_CONFIG_FLAGS` flag against +the command tree), and `tsc` (a command that reads config without `withCliConfigFlags`). + +**Exceptions** + +They live in [ADR 0031](../../docs/adr/0031-config-value-precedence.md) and nowhere else. Do not add a +per-command exception without updating the ADR and the guard's exemption list together. + ## Experimental feature registration -Resolve opt-in booleans with `command-internal/experimental-feature.ts`: environment `1`/`0` -overrides the project setting, and an unset or empty value uses the config. Invalid environment +Resolve opt-in booleans with `command-internal/experimental-feature.ts`: a shell boolean +(`1`, `t`, `T`, `TRUE`, `true`, `True` / `0`, `f`, `F`, `FALSE`, `false`, `False`; other casings such as `tRue` fail) overrides the project setting, and an unset or empty value uses the config. Invalid environment values are typed failures on applicable command paths. Disabled families are absent from the command tree, help, and completion; enabled help is marked experimental and stays out of stable generated command documentation. Environment opt-ins do not write project configuration, except diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f69581da9b..f8ae017494 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -6,8 +6,9 @@ configuration may change incompatibly while experimental. The family is opt in. `SUPABASE_EXPERIMENTAL_COMPUTE=1` or by setting `compute = true` under `[experimental]` in `supabase/config.toml`. -The environment variable accepts `1` to enable and `0` to disable. When it is -unset or empty, the config file is used; any other non-empty value reports an +The environment variable accepts a boolean (`1`, `t`, `T`, `TRUE`, `true`, `True` / `0`, `f`, `F`, `FALSE`, `false`, `False`; +other casings such as `tRue` fail) to enable or disable the family. When it is unset or empty, the config file is used; any +non-boolean value reports an invalid feature-flag value on Compute paths (including their completion), root help, or root-level completion before a subcommand is chosen. Unrelated commands do not resolve the Compute flag. Unreadable or malformed diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index bae1ff9868..ae47121fb5 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -173,7 +173,7 @@ unavailable. An invalid `SUPABASE_EXPERIMENTAL_STACK` value is still an error. For temporary selection, set `SUPABASE_EXPERIMENTAL_STACK=1` to select the new backend or `SUPABASE_EXPERIMENTAL_STACK=0` to select the legacy backend. This environment variable takes precedence over `experimental.stack`; an unset or empty value falls back to the file setting. -Other values are rejected. The override is applied before reading the project configuration. +Non-boolean values are rejected. The override is applied before reading the project configuration. `supabase services` follows the same backend selection. In stack mode it lists image versions and canonical `ghcr.io/supabase/cli/...` names from the installed CLI's artifact catalog, including diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index 35bcd0272d..8f90aa8ed4 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -25,7 +25,7 @@ User-authored configuration and migrations live in the repository: functions/ ``` -The CLI discovers the active project from the nearest ancestor containing `supabase/config.toml` +The CLI discovers the active project from the nearest ancestor containing `supabase/config.toml` or `supabase/config.json` (or the legacy config filename where supported), then passes that canonical project root to the managed stack identity. A stack ID is derived from the canonical project root, the current Git branch context (or `ordinary-workspace` outside Git), and the stack name. This identity is local diff --git a/apps/cli/src/auth/command-credentials.layer.ts b/apps/cli/src/auth/command-credentials.layer.ts index 12cb8acfa4..4ff06e2128 100644 --- a/apps/cli/src/auth/command-credentials.layer.ts +++ b/apps/cli/src/auth/command-credentials.layer.ts @@ -14,7 +14,7 @@ import type { PlatformError } from "effect/PlatformError"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../shared/auth/keyring-token.ts"; -import { DebugLogger, type DebugLoggerShape } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger, type DebugLoggerShape } from "../shared/output/debug-logger.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ACCESS_TOKEN_PATTERN, validateAccessToken } from "./access-token.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 223096c775..97c939db6d 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -4,7 +4,7 @@ import * as HttpClient from "effect/http/HttpClient"; import { CLI_VERSION } from "../shared/cli/version.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { DebugLogger } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { validateAccessToken } from "./access-token.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index 51875d1828..3a3da993a4 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -33,7 +33,6 @@ function mockCliSettings(opts: { projectHost?: string; }) { return Layer.succeed(CommandSettings, { - dbPassword: Option.none(), githubToken: Option.none(), workdirEnvValue: Option.none(), profile: opts.profile ?? "supabase", diff --git a/apps/cli/src/auth/http-debug.layer.ts b/apps/cli/src/auth/http-debug.layer.ts index e1bdc82b69..ce6ca1bda8 100644 --- a/apps/cli/src/auth/http-debug.layer.ts +++ b/apps/cli/src/auth/http-debug.layer.ts @@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/http"; import * as HttpClient from "effect/http/HttpClient"; import { dohFetchLayer } from "../command-internal/http-dns.ts"; -import { DebugLogger } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; /** * Query parameters that mean the URL *is* a credential. diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 3943c8dbe7..0d2caddd89 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -95,7 +95,9 @@ describe("supabase __complete", () => { ); expect(completionFailure.exitCode).toBe(1); expect(completionFailure.stdout).toBe(""); - expect(completionFailure.stderr).toContain("must be 0 or 1"); + expect(completionFailure.stderr).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="invalid" (sets experimental.stack): expected true or false.', + ); const invalidEnv = await runSupabase(["start", "--output-format=json"], { cwd: project.dir, diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 21936f5f3e..4b647b832c 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -9,7 +9,7 @@ import { RESOURCE_OUTPUT_FORMATS, } from "../command-internal/output-formats.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; -import { isValidBase0Int64, parseUintBase0 } from "../command-internal/parse-uint.ts"; +import { isValidBase0Int64, parseUintBase0 } from "../shared/config/parse-uint.ts"; import { parseStringSliceFlag } from "../command-internal/string-slice-flag.ts"; import { withAnalyticsContext } from "../shared/telemetry/analytics-context.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; @@ -26,6 +26,7 @@ import { ttyLayer } from "../shared/runtime/tty.layer.ts"; import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; /** * Implements the shell completion protocol that the generated scripts (`supabase @@ -1178,7 +1179,7 @@ export function defaultCompleteDeps( root, routingFailure, argv: process.argv.slice(2), - env: process.env, + env: ambientEnvironment(), stdoutWrite: (message) => { process.stdout.write(message); }, diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 8249a437d0..610bec2da5 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -1248,14 +1248,17 @@ describe("tryComplete", () => { root: undefined, routingFailure: Cause.fail( new StackRoutingError({ - message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + message: + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', }), ), }); expect(await tryComplete(deps)).toBe(true); expect(stdoutWrites).toEqual([]); expect(stderrWrites).toHaveLength(1); - expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + expect(stderrWrites[0]).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', + ); expect(stderrWrites[0]).toContain( "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=1 to enable stack commands, or 0 to use legacy start/stop/status.", ); diff --git a/apps/cli/src/cli/main.e2e.test.ts b/apps/cli/src/cli/main.e2e.test.ts index e78b29f02c..a04e6bbc25 100644 --- a/apps/cli/src/cli/main.e2e.test.ts +++ b/apps/cli/src/cli/main.e2e.test.ts @@ -17,7 +17,9 @@ describe("CLI feature routing", () => { expect(result.exitCode).toBe(1); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1"); + expect(result.stderr).toContain( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', + ); } finally { await project.cleanup(); home[Symbol.dispose](); diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index ee7f1c8796..80ed3b5331 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -8,6 +8,7 @@ import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { defaultCompleteDeps, tryComplete } from "./complete.ts"; import { resolveStackBackend } from "../command-internal/stack-backend.ts"; import { resolveComputeEnabled } from "../commands/experimental/compute/compute-backend.ts"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { cliEntrypointForFeatures } from "./root.ts"; const args = await Effect.runPromise( @@ -19,14 +20,13 @@ const args = await Effect.runPromise( const selectionExit = await Effect.runPromiseExit( Effect.gen(function* () { - const stackBackend = yield* resolveStackBackend({ args, cwd: process.cwd(), env: process.env }); + const stackBackend = yield* resolveStackBackend({ args, cwd: process.cwd() }); const computeEnabled = yield* resolveComputeEnabled({ args, cwd: process.cwd(), - env: process.env, }); return { stackBackend, computeEnabled }; - }).pipe(Effect.provide(BunServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer))), ); const { rootCommand: selectedRoot, agentDefaultOutputFormat } = cliEntrypointForFeatures( Exit.isSuccess(selectionExit) diff --git a/apps/cli/src/command-internal/argv-flag-reconcile.ts b/apps/cli/src/command-internal/argv-flag-reconcile.ts index ff1e6bcd39..cf3de05818 100644 --- a/apps/cli/src/command-internal/argv-flag-reconcile.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.ts @@ -10,6 +10,7 @@ import { ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; import { profileFilePath } from "../config/profile-file.ts"; +import { envValue } from "../shared/config/env-option.ts"; import { loadProfile, type LoadedProfile } from "./profile-load.ts"; import { parseStringSliceFlag } from "./string-slice-flag.ts"; import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; @@ -135,7 +136,7 @@ export const validateArgvWorkdir = Effect.fnUntraced(function* ( // `serviceOption`: absent outside the real CLI tree (handler-level tests // provide argv via `Stdio.layerTest`, not the global flag settings). const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(WorkdirFlag)); - const workdir = argvWorkdirValue(scan, parsedWorkdir, process.env["SUPABASE_WORKDIR"]); + const workdir = argvWorkdirValue(scan, parsedWorkdir, yield* envValue("SUPABASE_WORKDIR")); if (Option.isNone(workdir)) { return; } @@ -213,7 +214,7 @@ export const resolveArgvProfile = Effect.fnUntraced(function* ( ) { const parsedRaw = yield* Effect.serviceOption(ProfileFlag); const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase"); - const env = process.env["SUPABASE_PROFILE"]; + const env = yield* envValue("SUPABASE_PROFILE"); const envProfile = env !== undefined && env.length > 0 ? env : undefined; // The explicit token the raw-argv scan resolves, vs. the one the config layer's own @@ -253,7 +254,7 @@ export const resolveArgvProfile = Effect.fnUntraced(function* ( // while the config layer trims and maps empty to the default — a divergence the token // comparison below surfaces (e.g. a trailing newline fails to load as a profile). const fileRaw = yield* fs.value - .readFileString(profileFilePath(path.value, runtimeInfo.value.homeDir)) + .readFileString(yield* profileFilePath(path.value, runtimeInfo.value.homeDir)) .pipe(Effect.option); const argvToken = Option.isSome(argvExplicit) diff --git a/apps/cli/src/command-internal/cli-config-load.ts b/apps/cli/src/command-internal/cli-config-load.ts deleted file mode 100644 index 55dd5d2c85..0000000000 --- a/apps/cli/src/command-internal/cli-config-load.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CliProjectEnvironment } from "@supabase/config"; -import { - decodeCliConfigDocumentForValidationEffect as decodeDocumentWithCompat, - loadCliConfig as loadConfigWithCompat, - resolveCliConfigSubtree as resolveSubtreeWithCompat, - resolveCliConfigValue as resolveValueWithCompat, - type DecodeCliConfigDocumentForValidationEffectOptions, - type InternalLoadCliConfigOptions, -} from "@supabase/config/internal"; - -export type CliConfigLoadOptions = Omit; -export type CliConfigValidationOptions = Omit< - DecodeCliConfigDocumentForValidationEffectOptions, - "cliCompat" ->; - -/** The single `apps/cli` entrypoint that loads config with the CLI's own loader semantics. */ -export const loadCliConfig = (cwd: string, options?: CliConfigLoadOptions) => - loadConfigWithCompat(cwd, { ...options, cliCompat: true }); - -/** Decodes a pending document with the same semantics as {@link loadCliConfig}. */ -export const decodeCliConfigDocumentForValidation = ( - document: Record, - options: CliConfigValidationOptions, -) => decodeDocumentWithCompat(document, { ...options, cliCompat: true }); - -export const resolveCliConfigValue = ( - value: T, - cliProjectEnv: Pick, - configPath: string, -) => resolveValueWithCompat(value, cliProjectEnv, configPath, { cliCompat: true }); - -export const resolveCliConfigSubtree = ( - value: T, - cliProjectEnv: Pick, - pathPrefix: string, -) => resolveSubtreeWithCompat(value, cliProjectEnv, pathPrefix, { cliCompat: true }); diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index b1b6933cf1..3eed599f46 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -1,4 +1,5 @@ import { styleText } from "node:util"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; /** * Structural subset of a write stream that the colour gate inspects. Both @@ -24,7 +25,7 @@ export interface ColorStream { * Node's `FORCE_COLOR` is not honored — only the `CLICOLOR*` pair. */ function supportsColor(stream: ColorStream): boolean { - const env = process.env; + const env = ambientEnvironment(); if ((env["NO_COLOR"] ?? "") !== "") return false; const clicolorForce = env["CLICOLOR_FORCE"] ?? ""; const forced = clicolorForce !== "" && clicolorForce !== "0"; diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 167d3f415b..37851a5be3 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -15,15 +15,16 @@ import { } from "@supabase/config/internal"; import type { ConfigChange } from "@supabase/config"; import { operationDefinitions } from "@supabase/api/effect"; -import { Effect, FileSystem, Result, Schema, SchemaIssue } from "effect"; +import { Effect, FileSystem, Option, Result, Schema, SchemaIssue } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { decodeCliConfigDocumentForValidation, type CliConfigValidationOptions, -} from "./cli-config-load.ts"; +} from "../config/cli-config-validation.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; import type { ConfigTarget } from "./project-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; @@ -32,7 +33,7 @@ import { configIsRecord, configPathKey, } from "../commands/config/config.paths.ts"; -import { loadLocalConfig, relativeConfigPath } from "../commands/config/config.load.ts"; +import { loadDeclaredFileConfig, relativeConfigPath } from "../commands/config/config.load.ts"; import { CONFIG_CLASS_LABELS, configApiScope, @@ -377,7 +378,7 @@ function configPullFamiliesForChangePaths( * Runs {@link decodeCliConfigDocumentForValidation}, capturing only its own * `CliConfigParseError` failure into a `Result`. A genuinely malformed `.env`/`.env.local`, * or a filesystem failure reading one, is not a decode-attribution failure, so those - * propagate uncaught, matching how the real `loadCliConfig` call already handles them. + * propagate uncaught, matching how the resolved config load handles them. */ function decodeConfigPullValidation( document: Record, @@ -438,7 +439,7 @@ const CONFIG_PULL_VALIDATION_ROUND_CAP = 4; /** * `config pull`'s schema-validation gate: decodes the projected final document the way the - * next `loadCliConfig` call will (including a `[remotes.*]` destination's `remoteName`-merged + * next config load will (including a `[remotes.*]` destination's `remoteName`-merged * projection, since a block can pass the raw check yet still fail once selected), and never * writes a file the CLI itself couldn't load. A failure already present in * {@link configPullPreExistingFailingChangePathKeys} is ignored; a new one drops its @@ -531,25 +532,7 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { }); }); -/** Builds the file-load helpers for one `cliSettings.workdir`, narrowed to `workdir` and - * `explicitWorkdir` since that's all `loadLocalConfig` needs; only this family's own tagged - * error class is local. */ -function makeConfigLoader(cliSettings: { - readonly workdir: string; - readonly explicitWorkdir: boolean; -}) { - const toRelativeConfigPath = (path: string): string => - relativeConfigPath(cliSettings.workdir, path); - - const loadConfig = (projectRef: string | undefined) => - loadLocalConfig( - cliSettings, - projectRef, - (message) => new ConfigPullLoadConfigError({ message }), - ); - - return { toRelativeConfigPath, loadConfig }; -} +const makeLoadError = (message: string) => new ConfigPullLoadConfigError({ message }); /** * The paired base config load and its exact on-disk text, produced only by @@ -571,16 +554,15 @@ export interface ConfigPullSource { */ export const openConfigPullSource = Effect.fn("ConfigPull.openSource")(function* () { const cliSettings = yield* CommandSettings; - const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); - const loaded = yield* loadConfig(undefined); + const { loaded } = yield* loadDeclaredFileConfig(cliSettings, Option.none(), makeLoadError); if (loaded.rawText === undefined) { // The loader guarantees `rawText` for any file it parsed off disk; treat this like a // concurrent edit rather than re-reading, which would reopen the race this baseline // exists to close. return yield* new ConfigPullFileChangedError({ - message: `${toRelativeConfigPath(loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, + message: `${relativeConfigPath(cliSettings.workdir, loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, }); } @@ -602,7 +584,6 @@ export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const { ref, branch } = request.target; - const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); const branchLabelCandidate = branch !== undefined && !BRANCH_UUID_PATTERN.test(branch) ? branch : undefined; @@ -634,14 +615,14 @@ export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( // A brand-new block has nothing to overlay yet. let loaded = request.source.loaded; if (destination.kind === "remote" && !destination.created) { - loaded = yield* loadConfig(ref); + ({ loaded } = yield* loadDeclaredFileConfig(cliSettings, Option.some(ref), makeLoadError)); } const context: ConfigPullContext = { projectRef: ref, branch, configSchema: loaded.schemaRef ?? CLI_CONFIG_SCHEMA_URL, - configPath: toRelativeConfigPath(loaded.path), + configPath: relativeConfigPath(cliSettings.workdir, loaded.path), format: loaded.format, appliedRemote: loaded.appliedRemote, destination, @@ -753,6 +734,7 @@ export const applyConfigPullRun = Effect.fn("ConfigPull.apply")(function* (input readonly source: ConfigPullSource; }) { const fs = yield* FileSystem.FileSystem; + const configValues = yield* CliConfigValues; const { plan, context, configFilePath } = input.runPlan; yield* Effect.annotateCurrentSpan("config.write_count", plan.writes.length); @@ -790,12 +772,14 @@ export const applyConfigPullRun = Effect.fn("ConfigPull.apply")(function* (input message: `cannot write ${context.configPath}: ${configPullRefusalPhrase(reason)}${location} — ${detail}. ${configPullRefusalRemediation(reason)}`, }); } - yield* writeCliConfigDocumentText(configFilePath, editOutcome.text).pipe( - Effect.catchTag( - "CliConfigWriteError", - (cause) => new ConfigPullWriteError({ message: cause.message }), - ), - ); + yield* configValues + .writeThrough(writeCliConfigDocumentText(configFilePath, editOutcome.text)) + .pipe( + Effect.catchTag( + "CliConfigWriteError", + (cause) => new ConfigPullWriteError({ message: cause.message }), + ), + ); }); interface ChangeStatus { diff --git a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts index 17b8f86b53..72012d78a3 100644 --- a/apps/cli/src/command-internal/config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.parity.unit.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; +import { stringify as stringifyToml } from "smol-toml"; import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; @@ -13,6 +14,7 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { resolveStorageCredentials } from "./storage-credentials.ts"; import { resolveLocalConfigValues } from "./local-config-values.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; /** * Cross-caller parity coverage: for a table of shared misconfigurations, drives both real @@ -40,7 +42,7 @@ const readD = (workdir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(Layer.merge(BunServices.layer, cliConfigValuesTestLayer))); /** Drives D's real pipeline and asserts the failure message contains `message`. */ function failsWithD(tomlLines: ReadonlyArray, message: string) { @@ -69,9 +71,7 @@ function failsWithL( document?: Readonly>, ) { const config = baseConfig(overrides); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)).toThrow( - message, - ); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).toThrow(message); } interface ParityScenario { @@ -289,12 +289,26 @@ const scenarios: ReadonlyArray = [ message: "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", }, + { + name: "custom auth.external provider enabled without client_id", + toml: ["[auth.external.my_oidc]", "enabled = true"], + overrides: {}, + document: { auth: { external: { my_oidc: { enabled: true } } } }, + message: "Missing required field in config: auth.external.my_oidc.client_id", + }, + { + name: "custom auth.external provider enabled without secret", + toml: ["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"'], + overrides: {}, + document: { auth: { external: { my_oidc: { enabled: true, client_id: "cid" } } } }, + message: "Missing required field in config: auth.external.my_oidc.secret", + }, ]; // Explicitly skipped (only one caller runs the branch, or the branch isn't exercised the same // way by both — see the module header in `config-validate.ts` for the full out-of-scope list): -// - `remotes[*].project_id`, `auth.sms`, `auth.external` — D-only, never part of the shared -// validator (`ConfigValidationInput` has no fields for these at all). +// - `remotes[*].project_id` — D-only, never part of the shared validator +// (`ConfigValidationInput` has no field for it). // - `project_id`, `studio`, `local_smtp` — L-only, D has no equivalent sections. `api.tls` // presence is D-skipped for the same reason but is shared with S via // `validateApiTlsPresence` — see the S block below. @@ -315,13 +329,14 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => { // assert the identical message, so the shared branches cannot drift. describe("shared api + auth validation branches, cross-caller parity (S vs L)", () => { /** Drives S's real pipeline (`resolveStorageCredentials`, local branch) to failure. */ - const failsWithS = (config: CliConfig, message: string) => + const failsWithS = (overrides: Record, message: string) => Effect.gen(function* () { - const dir = withConfig(""); - const exit = yield* resolveStorageCredentials({ projectRef: "", config }).pipe( + const dir = withConfig(stringifyToml({ project_id: "test", ...overrides })); + const exit = yield* resolveStorageCredentials({ projectRef: "" }).pipe( Effect.provide( Layer.mergeAll( unusedStackServices, + cliConfigValuesTestLayer, BunServices.layer, runtimeInfoLayer, mockCommandSettings({ workdir: dir }), @@ -360,7 +375,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", Effect.gen(function* () { const message = "Missing required field in config: api.port"; failsWithL({ api: { port: 0 } }, message); - yield* failsWithS(baseConfig({ api: { port: 0 } }), message); + yield* failsWithS({ api: { port: 0 } }, message); }), ), ); @@ -370,10 +385,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", Effect.gen(function* () { const message = "Missing required field in config: api.tls.key_path"; failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); - yield* failsWithS( - baseConfig({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }), - message, - ); + yield* failsWithS({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message); }), ), ); @@ -383,21 +395,19 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)", Effect.gen(function* () { const message = "Invalid config for auth.jwt_secret. Must be at least 16 characters"; failsWithL({ auth: { jwt_secret: "short" } }, message); - yield* failsWithS(baseConfig({ auth: { jwt_secret: "short" } }), message); + yield* failsWithS({ auth: { jwt_secret: "short" } }, message); }), ), ); - it.effect( - "undecryptable encrypted: auth.service_role_key: S and L fail with the same message", - () => - isolated( - Effect.gen(function* () { - const message = "failed to parse config"; - const auth = { service_role_key: "encrypted:not-a-real-ciphertext" }; - failsWithL({ auth }, message); - yield* failsWithS(baseConfig({ auth }), message); - }), - ), + it.effect("undecryptable encrypted: auth.service_role_key fails S at resolvedConfig load", () => + isolated( + Effect.gen(function* () { + yield* failsWithS( + { auth: { service_role_key: "encrypted:not-a-real-ciphertext" } }, + "failed to parse config", + ); + }), + ), ); }); diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index d02d21efa3..d74c67175f 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -1,6 +1,7 @@ import { statSync } from "node:fs"; import { isAbsolute, join } from "node:path"; +import type { CliConfig } from "@supabase/config"; import { actionability, type CliErrorActionabilityDeclaration, @@ -9,6 +10,7 @@ import { } from "../shared/telemetry/error-actionability.ts"; import { canonicalPathForContainment, isPathContainedInRoot } from "./path-containment.ts"; import { BRANCH_PROJECT_REF_PATTERN } from "./ref-patterns.ts"; +import type { ResolvedAuthExternalProvider } from "./local-config-values.ts"; import { parseUrl } from "./storage-url.ts"; /** @@ -20,10 +22,11 @@ import { parseUrl } from "./storage-url.ts"; * throws `Error`. * * Per-command reimplementations of any branch {@link validateResolvedConfig} owns are forbidden; - * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` and - * `auth.sms`/`auth.external` need the raw pre-decode document, and `auth.jwt_secret` length lives - * in each key-generation flow. Each reader calls {@link validateResolvedConfig} once, after its - * own I/O reads, so an error from a branch here surfaces before a caller-only I/O error. + * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` needs the raw + * pre-decode document, and `auth.jwt_secret` length lives in each key-generation flow. Each reader + * calls {@link validateResolvedConfig} once, after its own I/O reads, so an error from a branch + * here surfaces before a caller-only I/O error. {@link validateAuthSmsProviders} and + * {@link validateAuthExternalProviders} are separate calls taking already-resolved inputs. */ // Re-exported under this module's established name; `ref-patterns.ts` is the canonical @@ -45,23 +48,6 @@ export const HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; export const CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; -// Accepted boolean string forms (`1`/`t`/`T`/`TRUE`/`true`/`True`, `0`/`f`/`F`/`FALSE`/`false`/`False`, -// and the empty string as false); any other value is a parse error. -const BOOL_TRUE_LITERALS = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const BOOL_FALSE_LITERALS = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); - -/** - * Parses a config bool value: accepts `1`/`t`/`T`/`TRUE`/`true`/`True` and - * `0`/`f`/`F`/`FALSE`/`false`/`False` (and the empty string as false), returns - * `undefined` for anything else (surfaced by callers as a `failed to parse config` error). - * Used by both D and L for `SUPABASE_*` bool-flavored env overrides and TOML bool decoding. - */ -export function parseBoolLiteral(value: string): boolean | undefined { - if (BOOL_TRUE_LITERALS.has(value)) return true; - if (BOOL_FALSE_LITERALS.has(value)) return false; - return undefined; -} - /** * Thrown by {@link validateResolvedConfig}. Does not override `.name` — it stays the inherited * `"Error"` — so `instanceof Error` and `.name` checks can't distinguish it from a plain @@ -711,3 +697,73 @@ export function apiTlsCertReadErrorMessage(cause: unknown): string { export function apiTlsKeyReadErrorMessage(cause: unknown): string { return `failed to read TLS key: ${messageOf(cause)}`; } + +/** + * Validates only the first enabled provider in the fixed priority order; a later + * enabled-but-incomplete provider is never checked. + */ +export function validateAuthSmsProviders(sms: CliConfig["auth"]["sms"]): void { + function requireField(provider: string, field: string, value: string | undefined): void { + if (value === undefined || value.length === 0) { + throw new ConfigValidateError( + `Missing required field in config: auth.sms.${provider}.${field}`, + ); + } + } + + if (sms.twilio.enabled) { + requireField("twilio", "account_sid", sms.twilio.account_sid); + requireField("twilio", "message_service_sid", sms.twilio.message_service_sid); + requireField("twilio", "auth_token", sms.twilio.auth_token); + return; + } + if (sms.twilio_verify.enabled) { + requireField("twilio_verify", "account_sid", sms.twilio_verify.account_sid); + requireField("twilio_verify", "message_service_sid", sms.twilio_verify.message_service_sid); + requireField("twilio_verify", "auth_token", sms.twilio_verify.auth_token); + return; + } + if (sms.messagebird.enabled) { + requireField("messagebird", "originator", sms.messagebird.originator); + requireField("messagebird", "access_key", sms.messagebird.access_key); + return; + } + if (sms.textlocal.enabled) { + requireField("textlocal", "sender", sms.textlocal.sender); + requireField("textlocal", "api_key", sms.textlocal.api_key); + return; + } + if (sms.vonage.enabled) { + requireField("vonage", "from", sms.vonage.from); + requireField("vonage", "api_key", sms.vonage.api_key); + requireField("vonage", "api_secret", sms.vonage.api_secret); + return; + } +} + +/** + * Validates required fields for every enabled `auth.external.` provider, including + * custom names `@supabase/config`'s schema drops at decode time, so `resolved` must come from + * `resolveAuthExternalProviders` over the effective document rather than the decoded config. + */ +export function validateAuthExternalProviders( + resolved: Readonly>, +): void { + for (const [name, provider] of Object.entries(resolved)) { + if (!provider.enabled) continue; + if (provider.clientId.length === 0) { + throw new ConfigValidateError( + `Missing required field in config: auth.external.${name}.client_id`, + ); + } + if ( + name !== "apple" && + name !== "google" && + (provider.secret === undefined || provider.secret.length === 0) + ) { + throw new ConfigValidateError( + `Missing required field in config: auth.external.${name}.secret`, + ); + } + } +} diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 9ad09db7eb..3b80e220ea 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -21,10 +21,10 @@ import { ConfigValidateError, type AuthInput, type ConfigValidationInput, - parseBoolLiteral, resolveEmailTemplateContentPath, validateResolvedConfig, } from "./config-validate.ts"; +import { parseBoolLiteral } from "../shared/config/config-bool.ts"; describe("parseBoolLiteral", () => { it("accepts the true forms", () => { @@ -42,6 +42,8 @@ describe("parseBoolLiteral", () => { it("returns undefined for a value outside the accepted set", () => { expect(parseBoolLiteral("yes")).toBeUndefined(); expect(parseBoolLiteral("2")).toBeUndefined(); + expect(parseBoolLiteral("tRuE")).toBeUndefined(); + expect(parseBoolLiteral("tRue")).toBeUndefined(); }); }); diff --git a/apps/cli/src/command-internal/config-value-passthrough.ts b/apps/cli/src/command-internal/config-value-passthrough.ts new file mode 100644 index 0000000000..e8b68e4bbe --- /dev/null +++ b/apps/cli/src/command-internal/config-value-passthrough.ts @@ -0,0 +1,52 @@ +import { Effect, Option } from "effect"; + +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../config/cli-config.errors.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { describeConfigLoadFailure } from "./resolved-config-context.ts"; + +type ConfigValueFailure = CliConfigValueError | CliConfigFlagConflictError; + +/** A config failure that names the offending value or flag, and so reaches the user unwrapped. */ +export const isConfigValueFailure = (cause: unknown): cause is ConfigValueFailure => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause._tag === "CliConfigValueError" || cause._tag === "CliConfigFlagConflictError"); + +/** + * Loads the resolved config ahead of helpers that fold every load failure into one message, so an + * invalid value or flag conflict surfaces as-is and only other failures take `toError`. The load + * is memoised, so the helper that follows reuses it. + */ +export const loadResolvedConfigSurfacingValueErrors = ( + workdir: string, + toError: (message: string) => E, + projectRef: Option.Option = Option.none(), +) => + Effect.gen(function* () { + const values = yield* CliConfigValues; + return yield* values + .load({ workdir, projectRef }) + .pipe( + Effect.mapError((cause) => + isConfigValueFailure(cause) ? cause : toError(describeConfigLoadFailure(cause)), + ), + ); + }); + +/** Fails with an invalid value or flag conflict, and ignores every other load failure. */ +export const failOnInvalidConfigValue = ( + workdir: string, + projectRef: Option.Option = Option.none(), +) => + Effect.gen(function* () { + const values = yield* CliConfigValues; + yield* values + .load({ workdir, projectRef }) + .pipe( + Effect.catch((cause) => (isConfigValueFailure(cause) ? Effect.fail(cause) : Effect.void)), + ); + }); diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index eb2da95569..4048e663b6 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -1,7 +1,6 @@ /** - * Resolves the config fields `startDatabase` needs from `config`/`projectEnvValues`, folding in - * any `SUPABASE_*` override. Shared by `db start` and `supabase start` so each field's derivation - * has one home. + * Resolves the config fields `startDatabase` needs from the effective `config`. Shared by + * `db start` and `supabase start` so each field's derivation has one home. * * Excludes `--exclude` gate evaluation, JWKS resolution, and the Postgres registry-image resolve: * their timing differs between the two callers, so `startDatabase` takes them as a @@ -12,34 +11,21 @@ import type { CliConfig } from "@supabase/config"; import { Effect, type FileSystem, type Path } from "effect"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; -import { makeRemoteWins } from "../db-config.toml-read.ts"; import { resolveDbImage } from "../db-image.ts"; import { resolveHealthTimeoutSeconds } from "../duration.ts"; -import { - envOverride, - envOverrideBool, - envOverrideMajorVersion, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - InvalidRealtimeIpVersionEnvOverrideError, -} from "../local-config-values.ts"; +import { narrowConfigEnum } from "../local-config-values.ts"; import { InvalidServiceVersionTagError, readServiceVersionOverrides, } from "../service-version-overrides.ts"; import { ramInBytes } from "../size-units.ts"; -import { tempPaths } from "../temp-paths.ts"; +import { tempPaths } from "../../shared/config/temp-paths.ts"; export interface DbBootstrapConfigInput { + /** The effective config: every override is already applied. */ readonly config: CliConfig; - readonly projectEnvValues: Readonly> | undefined; + readonly projectEnvValues?: Readonly> | undefined; readonly workdir: string; - /** - * Config keys a matched `[remotes.]` block set at override tier; every `envOverride*` call - * below must not re-apply a `SUPABASE_*` value for a field this set already covers. Defaults to - * empty for `db start`/`db reset`, which never resolve a remote block here. - */ - readonly remoteOverrideKeys?: ReadonlySet; } export interface DbBootstrapConfig { @@ -66,7 +52,7 @@ export interface DbBootstrapConfig { } /** - * Wraps a synchronous `envOverride*` read that throws on a malformed value into a typed failure + * Wraps a synchronous validation that throws on a malformed value into a typed failure * instead of an untyped Effect defect. * * @param dottedFieldPath - Config path embedded in the error message (`invalid config for @@ -87,11 +73,10 @@ function wrapConfigOverride( } /** - * Resolves every field {@link startDatabase} needs from `config`/`projectEnvValues`: values - * already folded with any `SUPABASE_*` override, ready to feed the Postgres container spec and - * the fresh-volume setup pipeline. + * Resolves every field {@link startDatabase} needs from the effective `config`, ready to feed the + * Postgres container spec and the fresh-volume setup pipeline. * - * @param mapConfigError - Lets each caller tag a malformed-override failure with its own error + * @param mapConfigError - Lets each caller tag a malformed-value failure with its own error * type. */ export const resolveDbBootstrapConfig = ( @@ -101,127 +86,27 @@ export const resolveDbBootstrapConfig = ( mapConfigError: (message: string) => E, ): Effect.Effect => Effect.gen(function* () { - const { config, projectEnvValues, workdir } = input; - const remoteOverrideKeys = input.remoteOverrideKeys ?? new Set(); - const remoteWins = makeRemoteWins(remoteOverrideKeys); - - // Not wrapped: checkDbToml already validates this override. A matched remote block's value - // wins over a conflicting SUPABASE_DB_MAJOR_VERSION. - const majorVersion = remoteWins("db.major_version") - ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. - // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env - // precedence as `majorVersion` applies to each. - const orioledbVersion = remoteWins("db.orioledb_version") - ? config.db.orioledb_version - : envOverride("SUPABASE_DB_ORIOLEDB_VERSION", config.db.orioledb_version, projectEnvValues); - const s3Host = remoteWins("experimental.s3_host") - ? config.experimental.s3_host - : envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", config.experimental.s3_host, projectEnvValues); - const s3Region = remoteWins("experimental.s3_region") - ? config.experimental.s3_region - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_REGION", - config.experimental.s3_region, - projectEnvValues, - ); - const s3AccessKey = remoteWins("experimental.s3_access_key") - ? config.experimental.s3_access_key - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - config.experimental.s3_access_key, - projectEnvValues, - ); - const s3SecretKey = remoteWins("experimental.s3_secret_key") - ? config.experimental.s3_secret_key - : envOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - config.experimental.s3_secret_key, - projectEnvValues, - ); + const { config, workdir } = input; - // The one-shot fresh-DB setup jobs use the effective, overridden enabled value and run - // regardless of `--exclude`. Wrapping here is `db start`'s only protection against a - // malformed override; it has no `--exclude`/gates equivalent of its own. - const realtimeEnabledForSetup = yield* wrapConfigOverride( - "realtime.enabled", - () => - remoteWins("realtime.enabled") - ? config.realtime.enabled - : envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), - mapConfigError, - ); - const storageEnabledForSetup = yield* wrapConfigOverride( - "storage.enabled", - () => - remoteWins("storage.enabled") - ? config.storage.enabled - : envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), - mapConfigError, - ); - const authEnabledForSetup = yield* wrapConfigOverride( - "auth.enabled", - () => - remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ), - mapConfigError, - ); + const majorVersion = config.db.major_version; + const orioledbVersion = config.db.orioledb_version; + const { s3_host: s3Host, s3_region: s3Region } = config.experimental; + const { s3_access_key: s3AccessKey, s3_secret_key: s3SecretKey } = config.experimental; - // The long-running Realtime container and the one-shot PG15+ setup job must see the same - // overridden value. + const realtimeEnabledForSetup = config.realtime.enabled; + const storageEnabledForSetup = config.storage.enabled; + const authEnabledForSetup = config.auth.enabled; const realtimeIpVersion = yield* wrapConfigOverride( "realtime.ip_version", - () => { - // `envOverrideRealtimeIpVersion` reads `process.env` unconditionally, so it can't be - // called at all when the remote block wins — a raw env var would still beat it. The - // throw below only narrows the type; the schema already guarantees one of these values. - if (remoteWins("realtime.ip_version")) { - const value = config.realtime.ip_version; - if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); - } - return value; - } - return envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); - }, - mapConfigError, - ); - const realtimeMaxHeaderLength = yield* wrapConfigOverride( - "realtime.max_header_length", - () => - remoteWins("realtime.max_header_length") - ? config.realtime.max_header_length - : envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), + () => narrowConfigEnum("realtime.ip_version", config.realtime.ip_version, ["IPv4", "IPv6"]), mapConfigError, ); + const realtimeMaxHeaderLength = config.realtime.max_header_length; - // Same reasoning as Realtime's IP version above. `@supabase/config`'s schema stores - // `file_size_limit` as a plain string without parsing it, so a malformed value must be - // validated eagerly here rather than surfacing later inside a container env builder. - const storageFileSizeLimit = remoteWins("storage.file_size_limit") - ? config.storage.file_size_limit - : (envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit); + // `@supabase/config`'s schema stores `file_size_limit` as a plain string without parsing it, + // so a malformed value must be validated eagerly here rather than surfacing later inside a + // container env builder. + const storageFileSizeLimit = config.storage.file_size_limit; yield* wrapConfigOverride( "storage.file_size_limit", () => ramInBytes(storageFileSizeLimit), @@ -246,11 +131,8 @@ export const resolveDbBootstrapConfig = ( majorVersion, ); - const dbHealthTimeout = remoteWins("db.health_timeout") - ? config.db.health_timeout - : envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); const dbHealthTimeoutSeconds = yield* Effect.try({ - try: () => resolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), + try: () => resolveHealthTimeoutSeconds(config.db.health_timeout), catch: (cause) => mapConfigError( `failed to parse config: ${cause instanceof Error ? cause.message : String(cause)}`, diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index 90581c32ed..025e477b4e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -24,15 +24,20 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; import type { DbConnectError, DbExecError } from "../db-connection.errors.ts"; +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../../config/cli-config.errors.ts"; +import type { ProjectRefReadError } from "../../shared/config/temp-paths.ts"; import { DbConfigLoadError } from "../db-config.errors.ts"; -import { checkDbToml, resolveSeedSqlPath } from "../db-config.toml-read.ts"; +import { checkDbToml } from "../db-config.toml-read.ts"; import { CLI_PROJECT_LABEL, localDbContainerId } from "../docker-ids.ts"; import { DockerRun, type DockerRunOpts } from "../docker-run.service.ts"; import { migrateAndSeed } from "../migrate-and-seed.ts"; import { MigrationApplyError, execSqlFile } from "../migration-apply.ts"; import { readMigrationTable } from "../migration-history.ts"; import { statementInstallsPgNet } from "../pg-net-guidance.ts"; -import type { MigrationSeedError, SeedConfig } from "../seed.ts"; +import type { MigrationSeedError } from "../seed.ts"; import { ramInBytes } from "../size-units.ts"; import { MigrationVaultError, type VaultSecret, upsertVaultSecrets } from "../vault.ts"; import { ensureImagesCached, type ImagePrepullError } from "./image-prepull.ts"; @@ -44,6 +49,7 @@ import { START_DB_INITIAL_SCHEMA_13_SQL } from "./templates/db-initial-schema-13 import { START_DB_INITIAL_SCHEMA_14_SQL } from "./templates/db-initial-schema-14.sql.ts"; import { STACK_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; import { startInternalDbPassword, startInternalDbUrl } from "./internal-db-connection.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -117,6 +123,9 @@ function dbSetupDockerReason( /** Every failure {@link startSetupLocalDatabase} can produce. */ export type StartSetupLocalDatabaseError = | DbConfigLoadError + | ProjectRefReadError + | CliConfigValueError + | CliConfigFlagConflictError | DbSetupError | MigrationVaultError | MigrationApplyError @@ -280,32 +289,6 @@ export interface StartSetupLocalDatabaseInput extends Omit< * `db reset`'s PG15 recreate passes its own resolved reset version instead. */ readonly version: string; - /** - * `db reset`'s `--no-seed`/`--sql-paths` overrides. `db start` has neither flag, so its caller - * passes `{ noSeed: false, sqlPaths: [] }`, which {@link resolveResetSeedConfig} reduces to the - * loaded `[db.seed]` config unchanged. - */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; -} - -/** - * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved `[db.seed]` - * config: `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` force-enables - * seeding and overrides `sqlPaths` (each pattern resolved against `supabase/`); an empty - * `--sql-paths` is a no-op. The two flags are mutually exclusive, validated by the caller before - * this runs. - */ -export function resolveResetSeedConfig( - seed: SeedConfig, - override: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }, - path: Path.Path, -): SeedConfig { - if (override.noSeed) return { ...seed, enabled: false }; - if (override.sqlPaths.length === 0) return seed; - return { - enabled: true, - sqlPaths: override.sqlPaths.map((pattern) => resolveSeedSqlPath(path, pattern)), - }; } const errMessage = (e: unknown): string => @@ -884,7 +867,7 @@ export const startSetupLocalDatabase = ( // Rides alongside the alias because a pooled-connection acquire failure surfaces the driver's // connect error verbatim, suggestion included. StartSetupLocalDatabaseError | DbConnectError, - Output | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path + Output | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path | CliConfigValues > => Effect.gen(function* () { const { session, fs, path, workdir } = input; @@ -907,7 +890,7 @@ export const startSetupLocalDatabase = ( // env-overridden config, not re-read from the caller's raw `CliConfig`. yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + seed: toml.seed, experimental: input.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, @@ -1033,8 +1016,8 @@ export const runDatabaseWebhooksSetup = (input: { /** * Runs {@link startSetupLocalDatabase} against a freshly-provisioned local Postgres: dials the * host-facing session, resolves the {@link resolveDbSetupPrelude} prelude, then runs the setup - * pipeline. `version`/`seedFlags` are the one difference between callers — `db start` always - * passes `""`/a no-op override, `db reset` passes its own resolved reset version and flags. + * pipeline. `version` is the one difference between callers — `db start` always passes `""`, + * `db reset` passes its own resolved reset version. */ export const runFreshDbSetup = ( spawner: Spawner, @@ -1047,13 +1030,18 @@ export const runFreshDbSetup = ( readonly hostname: string; readonly dbPort: number; readonly version: string; - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; readonly setup: FreshDbSetupInput; }, ): Effect.Effect< void, StartSetupLocalDatabaseError | DbConnectError | ImagePrepullError | E, - Output | DbConnection | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path + | Output + | DbConnection + | DockerRun + | RuntimeInfo + | FileSystem.FileSystem + | Path.Path + | CliConfigValues > => Effect.scoped( Effect.gen(function* () { @@ -1094,7 +1082,6 @@ export const runFreshDbSetup = ( projectEnvValues: setup.projectEnvValues, debug: setup.debug, version: input.version, - seedFlags: input.seedFlags, }); }), ).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts index aa58e77bc4..5d42a62baf 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts @@ -20,6 +20,7 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/process"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { DbExecError } from "../db-connection.errors.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; @@ -202,7 +203,6 @@ function baseInput( projectEnvValues: undefined, debug: false, version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, ...overrides, }; } @@ -227,6 +227,7 @@ const run = ( out.layer, docker.layer, mockRuntimeInfo({ platform: "darwin" }), + cliConfigValuesTestLayer, ), ), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index 267b997177..e50f4c3123 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -221,13 +221,13 @@ function streamContainerLogsOnce( [ Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); stdoutScanner.scan(chunk); }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); stderrScanner.scan(chunk); }), ), @@ -256,7 +256,7 @@ function dumpContainerLogs( ): Effect.Effect { return Effect.gen(function* () { yield* Effect.sync(() => { - globalThis.process.stderr.write(`${containerId} container logs:\n`); + process.stderr.write(`${containerId} container logs:\n`); }); return yield* streamContainerLogsOnce(spawner, containerId); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts new file mode 100644 index 0000000000..bfc264b89b --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.precedence.integration.test.ts @@ -0,0 +1,205 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/process"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; +import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { ConfigEnvPins } from "../../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../../tests/helpers/config-goldens.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { ExperimentalFlag } from "../global-flags.ts"; +import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const GOLDEN_DIR = "./testdata/config-precedence/local-container-inputs"; + +const BASE_CONFIG = `project_id = "golden-shadow" + +[api] +port = 55321 +schemas = ["public", "base_api"] + +[db] +port = 55322 + +[auth] +site_url = "http://base.example.com" +enable_signup = true + +[realtime] +enabled = true + +[storage] +enabled = true + +[experimental.pgdelta] +enabled = false +`; + +const REMOTE_BLOCK = ` +[remotes.prod] +project_id = "${TARGET_REF}" + +[remotes.prod.api] +schemas = ["public", "remote_api"] + +[remotes.prod.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.prod.db] +port = 58322 +`; + +const SHELL_ENV = { + SUPABASE_DB_PORT: "56322", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_REALTIME_ENABLED: "false", + SUPABASE_STORAGE_ENABLED: "false", +}; + +const DOTENV = [ + "SUPABASE_DB_PORT=57322", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_AUTH_SITE_URL=http://dotenv.example.com", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_REALTIME_ENABLED=false", + "SUPABASE_STORAGE_ENABLED=false", + "", +].join("\n"); + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "no-overrides", + name: "shadow container inputs for a linked ref with no overrides use the base config", + config: BASE_CONFIG, + }, + { + golden: "matched-remote", + name: "shadow container inputs apply a matched remote's port, schemas and auth settings", + config: BASE_CONFIG + REMOTE_BLOCK, + }, + { + golden: "shell-env", + name: "shadow container inputs follow shell env over base config", + config: BASE_CONFIG, + shellEnv: SHELL_ENV, + }, + { + golden: "project-dotenv", + name: "shadow container inputs follow supabase/.env over base config", + config: BASE_CONFIG, + dotenv: DOTENV, + }, + { + golden: "matched-remote-with-project-id-env", + name: "SUPABASE_PROJECT_ID beats a matched remote's project_id for the shadow container naming", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + }, + { + golden: "matched-remote-with-shell-env", + name: "shell env beats a matched remote in shadow container inputs while unset keys follow the remote", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: SHELL_ENV, + }, +]; + +const configEnvOnly = (env: Readonly>) => + Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SUPABASE_"))); + +const tempRoot = useTempWorkdir("container-inputs-golden-"); + +describe("local container inputs precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.live(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(fixture.shellEnv ?? {}); + + const layer = Layer.mergeAll( + BunServices.layer, + cliConfigValuesTestLayer, + Layer.succeed(ConfigEnvPins, fixture.shellEnv ?? {}), + mockRuntimeInfo({ platform: "linux" }), + Layer.succeed(CliArgs, { args: ["db", "diff"] }), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), + ), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => + value === undefined ? [] : [[name, value]], + ), + ), + { preserveEmptyStrings: true }, + ), + ), + ); + const spawner = ChildProcessSpawner.make(() => + Effect.die("the container inputs prelude must not spawn a process"), + ); + + const inputs = yield* Effect.gen(function* () { + return yield* buildLocalDbContainerInputs( + spawner, + workdir, + Option.none(), + "linux", + false, + TARGET_REF, + ); + }).pipe(Effect.provide(layer)); + + const { context, setup, ...rest } = inputs; + const golden = { + ...rest, + appliedRemote: Option.getOrUndefined(context.resolvedConfig.appliedRemote), + projectEnvValues: configEnvOnly(context.projectEnvValues), + projectId: context.projectId, + setup: { + ...setup, + jwks: undefined, + projectEnvValues: configEnvOnly(setup.projectEnvValues ?? {}), + }, + }; + yield* Effect.promise(() => + ctx + .expect(goldenJson(golden, { [workdir]: "" })) + .toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index 361de27210..ef41c9dd1c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -13,6 +13,7 @@ import { HttpClient } from "effect/http"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import type { GlobalFlag } from "effect/cli"; +import type { CliConfigValues } from "../../config/cli-config-values.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { resolveExperimentalWithProjectEnv } from "../global-flags.ts"; @@ -81,15 +82,10 @@ export const buildLocalDbContainerInputs = ( // so the shadow's container-spec fields reflect the matching `[remotes.]` override. // `db start`/`db reset` never pass this. projectRef?: string, - // Which keys the caller's own `readDbToml(..., ref)` read set from the matched remote block, - // so a remote-set field isn't overridden again by a conflicting `SUPABASE_*` env var. - // `db start`/`db reset` never pass a `projectRef`, so they never pass this either. - remoteOverrideKeys?: ReadonlySet, // `db start`'s handler already loads a {@link LocalProjectContext} before calling this // function (to validate config ahead of its own "already running" short-circuit). When - // provided, this function skips its own reload — `@supabase/config`'s `loadCliConfig` prints - // deprecated-config WARN lines to stderr, so reloading would print each warning twice. Must - // correspond to the same `workdir`/`projectRef` this call would otherwise use. + // provided, this function skips its own load. Must correspond to the same `workdir`/`projectRef` + // this call would otherwise use. preloadedContext?: LocalProjectContext, ): Effect.Effect< LocalDbContainerInputs, @@ -101,6 +97,7 @@ export const buildLocalDbContainerInputs = ( | GlobalFlag.Setting.Identifier<"experimental"> | CliArgs | HttpClient.HttpClient + | CliConfigValues > => Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; @@ -110,33 +107,26 @@ export const buildLocalDbContainerInputs = ( const context = preloadedContext ?? (yield* loadLocalProjectContext(workdir, mapError, projectRef)); - const { config, projectEnvValues, loaded, hostname, projectId } = context; + const { config, projectEnvValues, resolvedConfig, hostname, projectId } = context; + const document = resolvedConfig.loaded.document ?? {}; const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); const values = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - config, - hostname, - workdir, - projectEnvValues, - loaded?.document, - remoteOverrideKeys, - ), + try: () => resolveLocalConfigValues(config, hostname, workdir, document), catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), }); const bootstrapConfig = yield* resolveDbBootstrapConfig( fs, path, - { config, projectEnvValues, workdir, remoteOverrideKeys }, + { config, workdir }, mapError, ); // See {@link resolveDockerNetworkMode} for the full flag/env/fallback precedence. const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: yield* supabaseEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", projectEnvValues, ), @@ -160,11 +150,7 @@ export const buildLocalDbContainerInputs = ( port: values.dbPort, major_version: bootstrapConfig.majorVersion, orioledb_version: bootstrapConfig.orioledbVersion, - settings: resolveDbSettingsEnvOverrides( - config.db.settings, - projectEnvValues, - remoteOverrideKeys, - ), + settings: resolveDbSettingsEnvOverrides(config.db.settings), }, experimental: { ...config.experimental, @@ -215,22 +201,12 @@ export const buildLocalDbContainerInputs = ( dbUrl: values.dbUrl, jwtSecret: values.jwtSecret, // Lazy: only evaluated when `runFreshDbSetup` reaches realtime setup and it's enabled. - jwks: resolveLocalJwks( - config, - workdir, - values.jwtSecret, - projectEnvValues, - remoteOverrideKeys, - ).pipe( + jwks: resolveLocalJwks(config, workdir, values.jwtSecret).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.mapError((cause) => mapError(cause.message)), ), apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl( - loaded?.document, - projectEnvValues, - remoteOverrideKeys, - ), + authExternalUrl: resolveAuthExternalUrl(document), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts index 7bd9526ee1..d3199841ac 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts @@ -8,8 +8,10 @@ import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; +import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockContainerCliSpawner } from "../../../tests/helpers/local-reset.ts"; -import { DebugLogger } from "../debug-logger.service.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { runtimeInfoLayer } from "../../shared/runtime/runtime-info.layer.ts"; import { LocalDockerEngine, @@ -395,16 +397,64 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); describe("isLocalDbRunning", () => { - const probe = (spawnerLayer: Layer.Layer) => + const probe = ( + spawnerLayer: Layer.Layer, + options: { readonly toml?: string; readonly env?: Readonly> } = { + toml: 'project_id = "engine-probe"\n', + }, + ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workdir = mkdtempSync(join(tmpdir(), "ldbrun-")); - return yield* isLocalDbRunning(spawner, fs, path, workdir, "engine-probe").pipe( + if (options.toml !== undefined) { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), options.toml); + } + return yield* isLocalDbRunning(spawner, fs, path, workdir).pipe( Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), ); - }).pipe(Effect.provide(spawnerLayer), Effect.provide(BunServices.layer)); + }).pipe( + Effect.provide(configValuesLayer({ output: mockOutput().layer, env: options.env })), + Effect.provide(spawnerLayer), + Effect.provide(BunServices.layer), + ); + + it.live("probes the container named by config.toml's project_id", () => { + const asked: Array = []; + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer, { toml: 'project_id = "tomlproj"\n' }).pipe( + Effect.provideService(LocalDockerEngine, { + containerExists: (containerId) => + Effect.sync(() => { + asked.push(containerId); + }).pipe(Effect.as(Option.some(true))), + }), + Effect.map(() => { + expect(asked).toEqual(["supabase_db_tomlproj"]); + }), + ); + }); + + it.live("probes the container named by SUPABASE_PROJECT_ID over config.toml's project_id", () => { + const asked: Array = []; + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer, { + toml: 'project_id = "tomlproj"\n', + env: { SUPABASE_PROJECT_ID: "envproj" }, + }).pipe( + Effect.provideService(LocalDockerEngine, { + containerExists: (containerId) => + Effect.sync(() => { + asked.push(containerId); + }).pipe(Effect.as(Option.some(true))), + }), + Effect.map(() => { + expect(asked).toEqual(["supabase_db_envproj"]); + }), + ); + }); it.live("trusts a definitive Engine answer without spawning the container CLI", () => { const asked: Array = []; diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index 942cd990bb..ecf6803c9f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -9,12 +9,14 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; import { isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.ts"; -import { readDbToml } from "../db-config.toml-read.ts"; -import { resolveLocalProjectId, localDbContainerId } from "../docker-ids.ts"; +import { localDbContainerId } from "../docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.ts"; import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-suggest.ts"; import { redactHttpUrl } from "../../auth/http-debug.layer.ts"; -import { DebugLogger } from "../debug-logger.service.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { resolveDockerDaemonEndpoint } from "../hostname.ts"; @@ -311,33 +313,27 @@ const decodeChunks = (chunks: ReadonlyArray): string => { * * Asks the Engine API first ({@link LocalDockerEngine}) so a stalled `docker` binary can't block * the probe, and falls back to the container-CLI spawn (Podman fallback, daemon-down - * classification) only when the Engine gives no definitive answer. `resolveDbToml` is a - * best-effort read: only `projectId` is needed, and an unreadable `.env` falls back to the - * workdir basename. + * classification) only when the Engine gives no definitive answer. The project id is a + * best-effort read of the resolved config unless the caller passes its already-resolved id: an + * unreadable config falls back to the workdir basename. */ export function isLocalDbRunning( spawner: Spawner, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - configuredProjectId: string | undefined, -): Effect.Effect { + resolvedProjectId?: string, +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { - // Config was already validated (and any unresolved-env WARN already printed) by the - // caller; only the resolved projectId matters here. - const tomlProjectId = yield* readDbToml(fs, path, workdir, undefined, { - validate: false, - warnOnUnresolvedEnv: false, - }).pipe( - Effect.map((toml) => toml.projectId), - Effect.orElseSucceed(() => Option.none()), - ); - const projectId = resolveLocalProjectId( - configuredProjectId, - Option.getOrUndefined(tomlProjectId), - workdir, - ); + const values = yield* CliConfigValues; + const projectId = + resolvedProjectId ?? + (yield* values.load({ workdir, projectRef: Option.none() }).pipe( + Effect.flatMap((resolvedConfig) => resolvedConfig.get(CliConfigKeys.projectId)), + Effect.map(({ value }) => value), + Effect.orElseSucceed(() => sanitizeProjectId(path.basename(workdir))), + )); const containerId = localDbContainerId(projectId); // Engine probe first; `Option.none()` falls through to the CLI spawn below. const engine = yield* LocalDockerEngine; diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index 0ef0f625e1..601c7bf996 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -44,7 +44,6 @@ import { } from "./container-lifecycle.ts"; import { runFreshDbSetup, - resolveResetSeedConfig, applyApiPrivileges, applyDatabaseWebhooks, initSchema14, @@ -67,6 +66,7 @@ import { type KongReloadError, type RestartServicesError, } from "./restart-services.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -130,8 +130,6 @@ export interface RecreateLocalDatabaseInput { readonly dbHealthTimeoutSeconds: number; /** The resolved reset migration version (`""` for every pending migration). */ readonly version: string; - /** `db reset`'s `--no-seed`/`--sql-paths` — see {@link resolveResetSeedConfig}. */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; /** The same shape `start-database.ts`'s `StartDatabaseInput.setup` uses — hoisted to {@link FreshDbSetupInput}. */ readonly setup: FreshDbSetupInput; } @@ -264,6 +262,7 @@ const recreateLocalDatabase15 = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const output = yield* Output; @@ -292,7 +291,7 @@ const recreateLocalDatabase15 = ( }); // No fresh-volume gate needed: a reset just removed the volume above, so it's always fresh. - // Passes the resolved reset `version`/`seedFlags`, unlike `db start`'s own call. + // Passes the resolved reset `version`, unlike `db start`'s own call. yield* runFreshDbSetup(spawner, { fs: input.fs, path: input.path, @@ -302,7 +301,6 @@ const recreateLocalDatabase15 = ( hostname: input.hostname, dbPort: input.dbPort, version: input.version, - seedFlags: input.seedFlags, setup: input.setup, }); @@ -327,6 +325,7 @@ const recreateLocalDatabase14 = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const { setup, fs, path, workdir } = input; @@ -391,7 +390,7 @@ const recreateLocalDatabase14 = ( const session = yield* connectAs("postgres", "postgres"); yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), + seed: toml.seed, experimental: setup.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, @@ -418,6 +417,7 @@ export const recreateLocalDatabase = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => (input.setup.majorVersion <= 14 ? recreateLocalDatabase14(spawner, input) diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index c0bbef152d..6d36e9a426 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -1,8 +1,9 @@ /** * Resets the local database in-process — shared by `db reset`'s handler and the `db schema * declarative`/`sync` local-reset paths, so neither needs to shell out to a separate process. - * `db reset`'s own handler is the only caller that ever passes a non-empty - * `version`/`seedFlags` override; the declarative callers always want the plain full reset. + * `db reset`'s own handler is the only caller that ever passes a non-empty `version`; the + * declarative callers always want the plain full reset. Seeding follows the resolved config: + * `--no-seed`, `--sql-paths` and `SUPABASE_DB_SEED_ENABLED` all arrive through it. * * Always prints its own two stderr lines via `output.raw`, regardless of `output.format`, but * never the JSON `output.success(...)` envelope — that belongs to a top-level `db reset` @@ -29,11 +30,10 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; -import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; +import { checkDbToml, loadProjectEnvValues, readDbToml } from "../db-config.toml-read.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; import { hasConfiguredBuckets, seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; -import { resolveResetSeedConfig } from "./db-setup.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; @@ -80,14 +80,9 @@ const toLogMessage = (version: string): string => export interface ResetLocalDatabaseInput { /** The resolved reset migration version (`""` for every pending migration, `db reset`'s default). */ readonly version: string; - /** `db reset`'s `--no-seed`/`--sql-paths` — see `resolveResetSeedConfig`. */ - readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; } -const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { - version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, -}; +const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { version: "" }; const notRunning = () => new ResetLocalDbNotRunningError({ @@ -115,7 +110,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu const workdir = cliSettings.workdir; // Load the project env first so a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is // honored by the experimental gate below. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); @@ -191,7 +186,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu overlay: projectCatalogOverlay(toml, workdir), migrations: { workdir, - toml: { ...toml, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path) }, + toml, experimental, version: input.version, }, @@ -247,7 +242,10 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu interactive: false, yes, credentials, - resolvedConfig: { config: context.config, document: context.loaded?.document }, + resolvedConfig: { + config: context.config, + document: context.resolvedConfig.loaded.document, + }, projectEnvValues: projectEnv, workdir, }); @@ -270,13 +268,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu const debug = yield* DebugFlag; // Error if the local db container is down. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - workdir, - Option.getOrUndefined(cliSettings.projectId), - ); + const running = yield* isLocalDbRunning(spawner, fs, path, workdir); if (!running) { return yield* notRunning(); } @@ -293,7 +285,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu debug, ); const { - context: { projectId, hostname, config, loaded }, + context: { projectId, hostname, config, resolvedConfig }, values, bootstrapConfig, networkId, @@ -320,7 +312,6 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, version: input.version, - seedFlags: input.seedFlags, // `db reset` resolves `--experimental` earlier than this prelude does, via the nested-env // walk above; override the prelude's own `setup.experimental` with that value so the two // stay consistent. @@ -349,7 +340,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // `SUPABASE_YES` set in `supabase/.env` auto-confirms the bucket overwrite/prune // prompts. yes, - resolvedConfig: { config, document: loaded?.document }, + resolvedConfig: { config, document: resolvedConfig.loaded.document }, // The same nested-dotenv walk already resolved for `yes`/`experimental` above. projectEnvValues: projectEnv, }).pipe( diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index 6ecdd15040..d26eba6d1c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -34,7 +34,7 @@ export const rollbackStart = ( Effect.gen(function* () { // `dockerRemoveAll` doesn't print this; each caller owns its own status writer. yield* Effect.sync(() => { - globalThis.process.stderr.write("Stopping containers...\n"); + process.stderr.write("Stopping containers...\n"); }); let removedContainers: ReadonlyArray = []; yield* dockerRemoveAll( @@ -48,7 +48,7 @@ export const rollbackStart = ( ).pipe( Effect.catch((error) => Effect.sync(() => { - globalThis.process.stderr.write(`${error.message}\n`); + process.stderr.write(`${error.message}\n`); }), ), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 1a5178c37f..2407d1fb8c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -66,10 +66,9 @@ const withShadowCacheHome = ( value: string | undefined, body: Effect.Effect, ): Effect.Effect => - withEnvVar( - "SUPABASE_HOME", - join(tempRoot.current, "_supabase_home"), - withShadowCacheEnv(value, body), + withShadowCacheEnv( + value, + withEnvVar("SUPABASE_HOME", join(tempRoot.current, "_supabase_home"), body), ); function fakeCluster(opts: { readonly failConnect?: boolean } = {}) { @@ -146,7 +145,7 @@ const shadowCacheDir = (path: Path.Path) => shadowBaselineCacheDir(path); /** The snapshot tars in the global cache dir, whatever keys they belong to. */ const soleTarName = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, path: Path.Path) { const entries = yield* fs - .readDirectory(shadowCacheDir(path)) + .readDirectory(yield* shadowCacheDir(path)) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); return entries.filter((entry) => entry.endsWith(".tar")); }); @@ -287,7 +286,7 @@ describe("acquireShadowDatabase", () => { yield* coldRun(docker, input); // A concurrent writer SIGKILLed mid-export: its partial is older than 5 minutes. const abandoned = path.join( - shadowCacheDir(path), + yield* shadowCacheDir(path), "shadow-baseline-0011223344556677.tar.4242.partial", ); yield* fs.writeFileString(abandoned, "stale"); @@ -366,7 +365,7 @@ describe("acquireShadowDatabase", () => { // A regular file occupies the cache root's path, so its mkdir can never succeed — the // same terminal shape as an unwritable or root-squashed `SUPABASE_HOME`. The acquire // must degrade to the plain uncached shadow rather than pay a doomed export cycle. - const cacheDir = shadowCacheDir(path); + const cacheDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(path.dirname(cacheDir), { recursive: true }); yield* fs.writeFileString(cacheDir, "not a directory"); @@ -394,7 +393,7 @@ describe("acquireShadowDatabase", () => { // Recursive mkdir on an existing directory creates nothing and succeeds regardless of // permission, so the acquire's probe must check write access explicitly — otherwise a // read-only root selects the doomed cold cycle on every default-ON invocation. - const cacheDir = shadowCacheDir(path); + const cacheDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(cacheDir, { recursive: true }); chmodSync(cacheDir, 0o500); // chmod cannot revoke write access from a privileged user (root ignores permission @@ -498,13 +497,15 @@ describe("acquireShadowDatabase", () => { const tars = yield* soleTarName(fs, path); expect(tars).toHaveLength(1); expect(tars[0]).toMatch(/^shadow-baseline-[0-9a-f]{16}\.tar$/u); - const published = yield* fs.readFileString(path.join(shadowCacheDir(path), tars[0] ?? "")); + const published = yield* fs.readFileString( + path.join(yield* shadowCacheDir(path), tars[0] ?? ""), + ); expect(published).toBe(expectedTarFor(tars[0] ?? "")); expect(keyOf(tars[0] ?? "")).toBe(handle.snapshotKey); // The stamp made it all the way into the artifact — this is the entry the next run's // pre-restore scan requires, so a cold export that skipped it would never warm anything. expect(published).toContain(PGDATA_BASELINE_MARKER_ENTRY); - const leftovers = yield* fs.readDirectory(shadowCacheDir(path)); + const leftovers = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(leftovers.filter((entry) => entry.includes("partial"))).toEqual([]); yield* removeShadowDatabase(docker.spawner, handle.containerId); @@ -563,7 +564,7 @@ describe("acquireShadowDatabase", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const input = shadowInput(fs, path); - const tempDir = shadowCacheDir(path); + const tempDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); // An adversarially (or crash-) pre-created temp file at this process's own temp path, // world-readable. The pre-remove + `wx` exclusive-create guarantees a fresh 0600 inode. @@ -594,7 +595,7 @@ describe("acquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const tempDir = shadowCacheDir(path); + const tempDir = yield* shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); // A SIGKILLed export leftover (older than 5 minutes) and a live writer's fresh temp file. const abandoned = path.join(tempDir, "shadow-baseline-0123456789abcdef.tar.99999.partial"); @@ -635,14 +636,18 @@ describe("acquireShadowDatabase", () => { expect(rekeyed.baselinePresent).toBe(false); // An unrelated file in the cache directory is untouched by retention. - const stray = path.join(shadowCacheDir(path), "catalog-abc.json"); + const stray = path.join(yield* shadowCacheDir(path), "catalog-abc.json"); yield* fs.writeFileString(stray, "{}"); // mtime is the LRU ordinal, and the rapid-fire publishes below can land within the // filesystem's timestamp granularity, making an mtime tie's "oldest" ambiguous. Age the // first tar explicitly since this test asserts keep-cap behavior, not tie-breaking. const anHourAgo = new Date(Date.now() - 60 * 60 * 1000); - yield* fs.utimes(path.join(shadowCacheDir(path), first[0] ?? ""), anHourAgo, anHourAgo); + yield* fs.utimes( + path.join(yield* shadowCacheDir(path), first[0] ?? ""), + anHourAgo, + anHourAgo, + ); // Fill past keep-cap. The current key is retained, so siblings evict first. for (let i = 0; i < SHADOW_BASELINE_KEEP; i++) { @@ -827,7 +832,7 @@ describe("acquireShadowDatabase", () => { // The caller is about to reconnect, so the container is running again regardless. expect(docker.containers.get(handle.containerId)?.running).toBe(true); // Neither a published tar nor a half-written temp file survives. - const entries = yield* fs.readDirectory(shadowCacheDir(path)); + const entries = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(entries).toEqual([]); }), ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); @@ -865,7 +870,7 @@ describe("acquireShadowDatabase", () => { // check that treated "tar exists" as "sibling just published" would leave this // garbage in place forever. const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); yield* fs.writeFileString(tarPath, "not-a-real-snapshot"); yield* fallback.snapshotBaseline; expect(yield* soleTarName(fs, path)).toHaveLength(1); @@ -887,7 +892,7 @@ describe("acquireShadowDatabase", () => { const input = shadowInput(fs, path); yield* coldRun(docker, input); const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); // The published artifact is replaced by a tar that is well-formed but carries no PGDATA: // `docker cp -` would extract nothing, the entrypoint would `initdb` a fresh cluster, @@ -926,7 +931,7 @@ describe("acquireShadowDatabase", () => { const input = shadowInput(fs, path); yield* coldRun(docker, input); const [tarName = ""] = yield* soleTarName(fs, path); - const tarPath = path.join(shadowCacheDir(path), tarName); + const tarPath = path.join(yield* shadowCacheDir(path), tarName); // A perfectly restorable PGDATA that never ran the platform baseline — the failure // `data/PG_VERSION` alone cannot see: `docker cp -` extracts a genuine cluster, the @@ -972,8 +977,8 @@ describe("acquireShadowDatabase", () => { const keyA = coldA.snapshotKey ?? ""; const keyB = coldB.snapshotKey ?? ""; expect(keyA).not.toBe(keyB); - const tarPathB = path.join(shadowCacheDir(path), `shadow-baseline-${keyB}.tar`); - const tarPathA = path.join(shadowCacheDir(path), `shadow-baseline-${keyA}.tar`); + const tarPathB = path.join(yield* shadowCacheDir(path), `shadow-baseline-${keyB}.tar`); + const tarPathA = path.join(yield* shadowCacheDir(path), `shadow-baseline-${keyA}.tar`); // A's snapshot copied over B's cache file — the shape a copied `~/.supabase/cache` // directory, a restored backup, or a hand-renamed tar produces. Every entry the @@ -1092,10 +1097,10 @@ describe("acquireShadowDatabase", () => { expect(docker.stepCalls("cp-out")).toHaveLength(1); const tars = yield* soleTarName(fs, path); expect(tars).toHaveLength(1); - expect(yield* fs.readFileString(path.join(shadowCacheDir(path), tars[0] ?? ""))).toBe( - expectedTarFor(tars[0] ?? ""), - ); - const leftovers = yield* fs.readDirectory(shadowCacheDir(path)); + expect( + yield* fs.readFileString(path.join(yield* shadowCacheDir(path), tars[0] ?? "")), + ).toBe(expectedTarFor(tars[0] ?? "")); + const leftovers = yield* fs.readDirectory(yield* shadowCacheDir(path)); expect(leftovers.filter((entry) => entry.includes("partial"))).toEqual([]); expect(docker.containers.get(first.containerId)?.running).toBe(true); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 1f19737bb1..f41547f04d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -435,7 +435,7 @@ const SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; */ const sweepAbandonedShadowBaselinePartials = (input: ShadowSetupInput): Effect.Effect => Effect.gen(function* () { - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const entries = yield* input.fs .readDirectory(cacheDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -465,7 +465,7 @@ const sweepShadowBaselineRetention = ( retainFileName?: string, ): Effect.Effect => Effect.gen(function* () { - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const names = yield* input.fs .readDirectory(cacheDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -570,7 +570,7 @@ const writeShadowBaselineTar = ( const published = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); if (published) return; } - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); yield* input.fs .makeDirectory(cacheDir, { recursive: true, mode: 0o700 }) .pipe( @@ -699,7 +699,7 @@ export const peekShadowBaseline = ( if (Option.isNone(keyInputs)) return { state: "uncachable" } as const; const key = shadowCacheKey(keyInputs.value); const tarPath = input.path.join( - shadowBaselineCacheDir(input.path), + yield* shadowBaselineCacheDir(input.path), shadowBaselineTarFileName(key), ); const cached = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); @@ -899,7 +899,7 @@ export const acquireShadowDatabase = ( // can't be written. The mkdir alone isn't a sufficient probe — it succeeds on an // already-existing directory regardless of permission — so `access(W_OK)` catches a // pre-existing read-only root (EACCES, EROFS, a root-squashing NFS server). - const cacheDir = shadowBaselineCacheDir(input.path); + const cacheDir = yield* shadowBaselineCacheDir(input.path); const cacheRoot = yield* Effect.result( input.fs .makeDirectory(cacheDir, { recursive: true, mode: 0o700 }) diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 1f18672a7c..5a0cc780fa 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -49,6 +49,7 @@ import { buildPostgresStartContainerSpec, type PostgresStartServiceInput, } from "./postgres.service.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; const START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; // Printed when an existing volume is reused; unrelated to `--from-backup`. @@ -132,6 +133,7 @@ export const startDatabase = ( | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path + | CliConfigValues > => Effect.gen(function* () { const output = yield* Output; @@ -210,10 +212,7 @@ export const startDatabase = ( networkId: input.networkId, hostname: input.hostname, dbPort: input.dbPort, - // Every pending migration, no seed override — `db start` has neither `--no-seed` nor - // `--sql-paths`. version: "", - seedFlags: { noSeed: false, sqlPaths: [] }, setup: input.setup, }); } else if (fromBackup === undefined) { diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index 6e79729968..00237c2051 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -8,7 +8,7 @@ * {@link StartLocalDatabaseResult} discriminator instead of printing the terminal line itself. */ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { Output } from "../../shared/output/output.service.ts"; @@ -18,32 +18,9 @@ import { CommandSettings } from "../../config/command-settings.service.ts"; import { checkDbToml } from "../db-config.toml-read.ts"; import { DbConfigLoadError } from "../db-config.errors.ts"; import { - envOverride, - envOverrideApiMaxRows, - envOverrideAuthPasswordRequirements, - envOverrideBool, - envOverrideDefaultPoolSize, - envOverrideEdgeRuntimePolicy, - envOverrideMaxClientConn, - envOverridePoolMode, - envOverridePort, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - envOverrideUint, - resolveAuthEmail, - resolveAuthEmailSmtp, resolveAuthExternalProviders, - resolveAuthHooks, - resolveAuthMfa, - resolveAuthSms, - resolveDbSettingsEnvOverrides, - resolveGotrueOAuthServer, resolveGotruePasskeyWebauthn, - resolveGotrueRateLimit, - resolveGotrueSessions, - resolveGotrueWeb3, resolveLocalConfigValues, - resolveThirdPartyProviders, } from "../local-config-values.ts"; import { parseDuration, resolveHealthTimeoutSeconds } from "../duration.ts"; import { ramInBytes } from "../size-units.ts"; @@ -124,430 +101,69 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu (message) => new DbConfigLoadError({ message }), ); // This same `context` is passed into `buildLocalDbContainerInputs` below as - // `preloadedContext`, since a second `loadCliConfig` call would double-print - // deprecated-config-section warnings; that function returns the same context back verbatim. + // `preloadedContext`; that function returns the same context back verbatim. // `hostnameForValidation` here still feeds the discarded `resolveLocalConfigValues` call below. - const { config, projectEnvValues, loaded, hostname: hostnameForValidation } = context; + const { config, resolvedConfig, hostname: hostnameForValidation } = context; + const document = resolvedConfig.loaded.document ?? {}; // Every duration config field is decoded in this same unconditional pass, before Docker is // touched or the already-running check runs. The parsed values are discarded; only the // fail-fast behavior matters. - const authDocForValidation = asRecord(loaded?.document?.["auth"]); - const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => - resolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), - ); yield* wrapDbConfigOverride("auth.email.max_frequency", () => - parseDuration(resolvedEmailForValidation.max_frequency), - ); - yield* wrapDbConfigOverride("auth.email.smtp", () => - resolveAuthEmailSmtp(authDocForValidation, projectEnvValues), - ); - const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => - resolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), + parseDuration(config.auth.email.max_frequency), ); yield* wrapDbConfigOverride("auth.sms.max_frequency", () => - parseDuration(smsForValidation.max_frequency), - ); - const authEnabledForValidation = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, + parseDuration(config.auth.sms.max_frequency), ); if ( - authEnabledForValidation && - !smsForValidation.twilio.enabled && - !smsForValidation.twilio_verify.enabled && - !smsForValidation.messagebird.enabled && - !smsForValidation.textlocal.enabled && - !smsForValidation.vonage.enabled && - envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - config.auth.sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ) + config.auth.enabled && + !config.auth.sms.twilio.enabled && + !config.auth.sms.twilio_verify.enabled && + !config.auth.sms.messagebird.enabled && + !config.auth.sms.textlocal.enabled && + !config.auth.sms.vonage.enabled && + config.auth.sms.enable_signup ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } - const gotrueSessionsForValidation = resolveGotrueSessions(config.auth.sessions, projectEnvValues); - if (gotrueSessionsForValidation?.timebox !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.timebox", () => - parseDuration(gotrueSessionsForValidation.timebox!), - ); + const { timebox, inactivity_timeout: inactivityTimeout } = config.auth.sessions ?? {}; + if (timebox !== undefined) { + yield* wrapDbConfigOverride("auth.sessions.timebox", () => parseDuration(timebox)); } - if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { + if (inactivityTimeout !== undefined) { yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => - parseDuration(gotrueSessionsForValidation.inactivity_timeout!), + parseDuration(inactivityTimeout), ); } yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => - parseDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), - ); - yield* wrapDbConfigOverride("auth.rate_limit", () => - resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.jwt_expiry", () => - envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_signup", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_anonymous_sign_ins", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_refresh_token_rotation", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.refresh_token_reuse_interval", () => - envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.enable_manual_linking", () => - envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.minimum_password_length", () => - envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("auth.password_requirements", () => - envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues), - ); - - yield* wrapDbConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.oauth_server", () => - resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.passkey", () => - resolveGotruePasskeyWebauthn(loaded?.document, projectEnvValues), + parseDuration(config.auth.mfa.phone.max_frequency), ); + yield* wrapDbConfigOverride("auth.passkey", () => resolveGotruePasskeyWebauthn(document)); yield* wrapDbConfigOverride("auth.external", () => - resolveAuthExternalProviders(authDocForValidation, config.auth.external, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.third_party", () => - resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.hook", () => - resolveAuthHooks(authDocForValidation, config.auth.hook, projectEnvValues), - ); - - yield* wrapDbConfigOverride("api.enabled", () => - envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), - ); - yield* wrapDbConfigOverride("api.tls.enabled", () => - envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("api.max_rows", () => - envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), - ); - yield* wrapDbConfigOverride("api.port", () => - envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), - ); - - yield* wrapDbConfigOverride("storage.vector.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_VECTOR_ENABLED", - config.storage.vector.enabled, - "storage.vector.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.s3_protocol.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_ANALYTICS_ENABLED", - config.storage.analytics.enabled, - "storage.analytics.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_namespaces", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", - "storage.analytics.max_namespaces", - config.storage.analytics.max_namespaces, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_tables", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", - "storage.analytics.max_tables", - config.storage.analytics.max_tables, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.analytics.max_catalogs", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", - "storage.analytics.max_catalogs", - config.storage.analytics.max_catalogs, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.vector.max_buckets", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", - "storage.vector.max_buckets", - config.storage.vector.max_buckets, - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.vector.max_indexes", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", - "storage.vector.max_indexes", - config.storage.vector.max_indexes, - projectEnvValues, - ), - ); - const imageTransformationSectionPresent = - asRecord(asRecord(loaded?.document?.["storage"])?.["image_transformation"]) !== undefined; - if (imageTransformationSectionPresent) { - yield* wrapDbConfigOverride("storage.image_transformation.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - config.storage.image_transformation?.enabled ?? false, - "storage.image_transformation.enabled", - projectEnvValues, - ), - ); - } - - const localSmtpPortForValidation = yield* wrapDbConfigOverride("local_smtp.port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("local_smtp.smtp_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", - config.local_smtp.smtp_port ?? 0, - "local_smtp.smtp_port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("local_smtp.pop3_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_POP3_PORT", - config.local_smtp.pop3_port ?? 0, - "local_smtp.pop3_port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("analytics.port", () => - envOverridePort( - "SUPABASE_ANALYTICS_PORT", - config.analytics.port, - "analytics.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("analytics.vector_port", () => - envOverridePort( - "SUPABASE_ANALYTICS_VECTOR_PORT", - config.analytics.vector_port ?? 0, - "analytics.vector_port", - projectEnvValues, - ), - ); - - yield* wrapDbConfigOverride("db.pooler.enabled", () => - envOverrideBool( - "SUPABASE_DB_POOLER_ENABLED", - config.db.pooler.enabled, - "db.pooler.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.pooler.port", () => - envOverridePort( - "SUPABASE_DB_POOLER_PORT", - config.db.pooler.port, - "db.pooler.port", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.pooler.pool_mode", () => - envOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), - ); - yield* wrapDbConfigOverride("db.pooler.default_pool_size", () => - envOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), - ); - yield* wrapDbConfigOverride("db.pooler.max_client_conn", () => - envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), - ); - - yield* wrapDbConfigOverride("edge_runtime.policy", () => - envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), - ); - yield* wrapDbConfigOverride("edge_runtime.inspector_port", () => - envOverridePort( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - config.edge_runtime.inspector_port, - "edge_runtime.inspector_port", - projectEnvValues, - ), - ); - - yield* wrapDbConfigOverride("realtime.ip_version", () => - envOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), - ); - yield* wrapDbConfigOverride("realtime.max_header_length", () => - envOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), - ); - - yield* wrapDbConfigOverride("db.settings", () => - resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - ); - - yield* wrapDbConfigOverride("realtime.enabled", () => - envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("storage.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), + resolveAuthExternalProviders(asRecord(document["auth"]), config.auth.external), ); yield* wrapDbConfigOverride("storage.file_size_limit", () => - ramInBytes( - envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit, - ), + ramInBytes(config.storage.file_size_limit), ); yield* wrapDbConfigOverride("db.health_timeout", () => - resolveHealthTimeoutSeconds( - envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues) ?? - config.db.health_timeout, - ), + resolveHealthTimeoutSeconds(config.db.health_timeout), ); - yield* wrapDbConfigOverride("edge_runtime.enabled", () => - envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ), - ); - yield* wrapDbConfigOverride("db.network_restrictions.enabled", () => - envOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ), - ); - const sslEnforcementSectionPresent = - asRecord(asRecord(loaded?.document?.["db"])?.["ssl_enforcement"]) !== undefined; - if (sslEnforcementSectionPresent) { - yield* wrapDbConfigOverride("db.ssl_enforcement.enabled", () => - envOverrideBool( - "SUPABASE_DB_SSL_ENFORCEMENT_ENABLED", - config.db.ssl_enforcement?.enabled ?? false, - "db.ssl_enforcement.enabled", - projectEnvValues, - ), - ); - } - const studioEnabledForValidation = yield* wrapDbConfigOverride("studio.enabled", () => - envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ), - ); - const studioPortForValidation = yield* wrapDbConfigOverride("studio.port", () => - envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues), - ); - if (studioEnabledForValidation && studioPortForValidation === 0) { + if (config.studio.enabled && config.studio.port === 0) { yield* Effect.fail( new DbConfigLoadError({ message: "Missing required field in config: studio.port" }), ); } - const studioApiUrlForValidation = - envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url; - if (studioEnabledForValidation) { + if (config.studio.enabled) { yield* Effect.try({ - try: () => parseUrl(studioApiUrlForValidation), + try: () => parseUrl(config.studio.api_url), catch: (cause) => new DbConfigLoadError({ message: `Invalid config for studio.api_url: ${cause instanceof Error ? cause.message : String(cause)}`, }), }); } - const localSmtpEnabledForValidation = yield* wrapDbConfigOverride("local_smtp.enabled", () => - envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ), - ); - if (localSmtpEnabledForValidation && localSmtpPortForValidation === 0) { + if (config.local_smtp.enabled && config.local_smtp.port === 0) { yield* Effect.fail( new DbConfigLoadError({ message: "Missing required field in config: local_smtp.port" }), ); @@ -561,13 +177,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // re-resolves the real values. yield* Effect.try({ try: () => - resolveLocalConfigValues( - config, - hostnameForValidation, - cliSettings.workdir, - projectEnvValues, - loaded?.document, - ), + resolveLocalConfigValues(config, hostnameForValidation, cliSettings.workdir, document), catch: (cause) => new DbConfigLoadError({ message: cause instanceof Error ? cause.message : String(cause), @@ -576,13 +186,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // If the db container is already up, tell the caller and stop here. Runs after the config // load/validation above. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - cliSettings.workdir, - Option.getOrUndefined(cliSettings.projectId), - ); + const running = yield* isLocalDbRunning(spawner, fs, path, cliSettings.workdir); yield* Effect.annotateCurrentSpan("db.already_running", running); if (running) { return { status: "already-running" } satisfies StartLocalDatabaseResult; @@ -609,7 +213,6 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu runtimeInfo.platform, debug, undefined, - undefined, context, ); const { diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json new file mode 100644 index 0000000000..a49452a620 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "shell-project-id", + "workdir": "" + }, + "dbContainerId": "supabase_db_shell-project-id", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_shell-project-id", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_shell-project-id", + "projectId": "shell-project-id", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "shell-project-id", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "shell-project-id", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://remote.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://remote.example.com", + "dbPort": 58322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "shell-project-id", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json new file mode 100644 index 0000000000..9c45da7b15 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "abcdefghijklmnopqrst", + "workdir": "" + }, + "dbContainerId": "supabase_db_abcdefghijklmnopqrst", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_abcdefghijklmnopqrst", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_abcdefghijklmnopqrst", + "projectId": "abcdefghijklmnopqrst", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "abcdefghijklmnopqrst", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://shell.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://shell.example.com", + "dbPort": 56322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "abcdefghijklmnopqrst", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json new file mode 100644 index 0000000000..a0698bac71 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": "prod", + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "abcdefghijklmnopqrst", + "workdir": "" + }, + "dbContainerId": "supabase_db_abcdefghijklmnopqrst", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_abcdefghijklmnopqrst", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_abcdefghijklmnopqrst", + "projectId": "abcdefghijklmnopqrst", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "abcdefghijklmnopqrst", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 58322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://remote.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://remote.example.com", + "dbPort": 58322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "abcdefghijklmnopqrst", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json new file mode 100644 index 0000000000..c35d4d806b --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": true, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": true, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": true, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://base.example.com", + "storageEnabledForSetup": true, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": true, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://base.example.com", + "dbPort": 55322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json new file mode 100644 index 0000000000..713f623162 --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json @@ -0,0 +1,602 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STORAGE_ENABLED": "false" + }, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "dotenv_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://dotenv.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STORAGE_ENABLED": "false" + }, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://dotenv.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://dotenv.example.com", + "dbPort": 57322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json new file mode 100644 index 0000000000..a6ab8caf6f --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json @@ -0,0 +1,588 @@ +{ + "appliedRemote": null, + "bootstrapConfig": { + "authEnabledForSetup": true, + "dbHealthTimeoutSeconds": 120, + "majorVersion": 17, + "orioledbVersion": null, + "postgresConfigImage": "supabase/postgres:17.11.0.004", + "postgresImage": "supabase/postgres:17.11.0.004", + "realtimeEnabledForSetup": false, + "realtimeIpVersion": "IPv4", + "realtimeMaxHeaderLength": 4096, + "s3AccessKey": null, + "s3Host": null, + "s3Region": null, + "s3SecretKey": null, + "serviceVersionOverrides": {}, + "storageEnabledForSetup": false, + "storageFileSizeLimit": "50MiB", + "storageTargetMigration": "" + }, + "containerOpts": { + "extraHosts": [ + "host.docker.internal:host-gateway" + ], + "isBitbucketPipeline": false, + "projectId": "golden-shadow", + "workdir": "" + }, + "dbContainerId": "supabase_db_golden-shadow", + "dbHealthTimeoutSeconds": 120, + "experimental": false, + "networkId": "supabase_network_golden-shadow", + "postgresSpecBase": { + "configImage": "supabase/postgres:17.11.0.004", + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "orioledb_version": null, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "s3_access_key": null, + "s3_host": null, + "s3_region": null, + "s3_secret_key": null, + "webhooks": { + "enabled": false + } + }, + "jwtExpiry": 3600, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "networkId": "supabase_network_golden-shadow", + "projectId": "golden-shadow", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275" + }, + "projectEnvValues": {}, + "projectId": "golden-shadow", + "resolvePostgresImage": { + "~effect/Effect/args": {} + }, + "setup": { + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiUrl": "http://127.0.0.1:55321", + "authEnabledForSetup": true, + "authExternalUrl": null, + "config": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-shadow", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": false, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + }, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "debug": false, + "experimental": false, + "jwks": null, + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "majorVersion": 17, + "projectEnvValues": {}, + "realtimeEnabledForSetup": false, + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "serviceVersionOverrides": {}, + "siteUrl": "http://shell.example.com", + "storageEnabledForSetup": false, + "storageTargetMigration": "" + }, + "values": { + "analyticsBackend": "postgres", + "analyticsEnabled": true, + "anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "apiPort": 55321, + "apiUrl": "http://127.0.0.1:55321", + "authAdditionalRedirectUrls": [ + "https://127.0.0.1:3000" + ], + "authEnableAnonymousSignIns": false, + "authEnableManualLinking": false, + "authEnableRefreshTokenRotation": true, + "authEnableSignup": false, + "authJwtExpiry": 3600, + "authJwtIssuer": null, + "authMinimumPasswordLength": 6, + "authPasswordRequirements": "", + "authRefreshTokenReuseInterval": 10, + "authSiteUrl": "http://shell.example.com", + "dbPort": 56322, + "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", + "edgeRuntimeDenoVersion": 2, + "functionsUrl": "http://127.0.0.1:55321/functions/v1", + "gcpJwtPath": "", + "gcpProjectId": "", + "gcpProjectNumber": "", + "graphqlUrl": "http://127.0.0.1:55321/graphql/v1", + "jwtSecret": "super-secret-jwt-token-with-at-least-32-characters-long", + "mailpitUrl": "http://127.0.0.1:54324", + "mcpUrl": "http://127.0.0.1:55321/mcp", + "openaiApiKey": null, + "projectId": "golden-shadow", + "publishableKey": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "restUrl": "http://127.0.0.1:55321/rest/v1", + "rootKey": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "secretKey": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "serviceRoleKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "storageS3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "storageS3Region": "local", + "storageS3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "storageS3Url": "http://127.0.0.1:55321/storage/v1/s3", + "studioPort": 54323, + "studioUrl": "http://127.0.0.1:54323" + } +} diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 4d8fe9a781..c0c450e4ca 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -16,6 +16,15 @@ export class DbConfigParseUrlError extends Data.TaggedError("DbConfigParseUrlErr } } +/** `--password` combined with `--db-url` or `--local`, where it can never apply. */ +export class DbPasswordFlagsError extends Data.TaggedError("DbPasswordFlagsError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `supabase/config.toml` exists but could not be read or parsed: the read/decode * error aborts the load, rather than silently running against the default local database. @@ -30,6 +39,19 @@ export class DbConfigLoadError extends Data.TaggedError("DbConfigLoadError")<{ } } +type PassthroughLoadFailureTag = + | "ProjectRefReadError" + | "CliConfigValueError" + | "CliConfigFlagConflictError"; + +/** Config-load failures that keep their own tag instead of becoming {@link DbConfigLoadError}. */ +export const isPassthroughLoadFailure = ( + error: E, +): error is Extract => + error._tag === "ProjectRefReadError" || + error._tag === "CliConfigValueError" || + error._tag === "CliConfigFlagConflictError"; + /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class DbConfigLoginRoleNetworkError extends Data.TaggedError( "DbConfigLoginRoleNetworkError", diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 27c9ead16e..a08142e8a1 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -40,6 +40,8 @@ import { mockTty, } from "../../tests/helpers/mocks.ts"; import { VALID_TOKEN, mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { pinnedConfigProvider } from "../../tests/helpers/config-env-pins.ts"; +import { flagInput, withHermeticShellTier } from "../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../tests/helpers/unused-stack.ts"; import { DebugFlag, @@ -48,7 +50,10 @@ import { ProfileFlag, WorkdirFlag, } from "./global-flags.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; @@ -70,6 +75,15 @@ const mockDbConnection = Layer.succeed(DbConnection, { connect: () => Effect.die("unexpected connect() in --local/--db-url resolver test"), }); +const explicitEnv = ( + env: Readonly> | undefined, +): Record => + Object.fromEntries( + Object.entries(env ?? {}).flatMap(([name, value]): Array<[string, string]> => + value === undefined ? [] : [[name, value]], + ), + ); + function buildResolver( workdir: string, opts: { @@ -78,9 +92,18 @@ function buildResolver( readonly dbConnection?: Layer.Layer; readonly configEnv?: Record; readonly stackApi?: Layer.Layer; + readonly flagPassword?: string; } = {}, ) { const deps = Layer.mergeAll( + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs( + opts.flagPassword === undefined + ? [] + : [flagInput("linkedDb.password", "password", opts.flagPassword)], + ), + ), mockCommandSettings({ workdir, projectHost: opts.projectHost ?? "supabase.co", @@ -107,13 +130,18 @@ function buildResolver( Layer.provide(BunServices.layer), ), BunServices.layer, - ConfigProvider.layer( - ConfigProvider.fromEnvRecord(opts.configEnv ?? {}, { preserveEmptyStrings: true }), - ), + Layer.effect(ConfigProvider.ConfigProvider, pinnedConfigProvider(explicitEnv(opts.configEnv))), ); + const hermeticValues = Layer.effect( + CliConfigValues, + Effect.map(Effect.service(CliConfigValues), (real) => + withHermeticShellTier(real, explicitEnv(opts.configEnv)), + ), + ).pipe(Layer.provide(cliConfigValuesLayer.pipe(Layer.provide(deps)))); + const depsWithValues = Layer.merge(deps, hermeticValues); return opts.stackApi === undefined - ? dbConfigLayer.pipe(Layer.provide(deps)) - : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(deps, opts.stackApi))); + ? dbConfigLayer.pipe(Layer.provide(depsWithValues)) + : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(depsWithValues, opts.stackApi))); } function withWorkdir(toml?: string) { @@ -139,9 +167,9 @@ const resolve = ( return yield* resolver.resolve(flags); }).pipe( Effect.provide(buildResolver(workdir, opts)), - Effect.provideService( + Effect.provideServiceEffect( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(opts?.configEnv ?? {}, { preserveEmptyStrings: true }), + pinnedConfigProvider(explicitEnv(opts?.configEnv)), ), ); @@ -155,9 +183,9 @@ const resolvePoolerFallback = ( return yield* resolver.resolvePoolerFallback(flags); }).pipe( Effect.provide(buildResolver(workdir, opts)), - Effect.provideService( + Effect.provideServiceEffect( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(opts?.configEnv ?? {}, { preserveEmptyStrings: true }), + pinnedConfigProvider(explicitEnv(opts?.configEnv)), ), ); @@ -415,6 +443,44 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); +describe("dbConfigResolver --password rejection", () => { + const failureTag = (exit: Exit.Exit) => + Exit.isFailure(exit) ? JSON.stringify(exit.cause) : ""; + + const targets: ReadonlyArray = [ + ["--db-url", dbUrlFlags("postgresql://u:p@db.example.com:5432/postgres")], + ["--local", localFlags], + ["the default local target", { ...localFlags, connType: undefined }], + ]; + for (const [label, flags] of targets) { + it.effect(`rejects --password with ${label}`, () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolve(dir, { ...flags, password: Option.some("pw") }); + }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(failureTag(exit)).toContain("DbPasswordFlagsError"); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + } + + it.effect("accepts an absent --password with --db-url", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolve(dir, { + ...dbUrlFlags("postgresql://u:p@db.example.com:5432/postgres"), + password: Option.none(), + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); +}); + describe("dbConfigResolver (db-url under the stack backend)", () => { const STACK_SQL_PORT = 54329; const stackUrl = (port: number) => @@ -1143,7 +1209,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje const requests: Array<{ readonly method: string; readonly path: string }> = []; const dbConnection = Layer.succeed(DbConnection, { connect: () => - Effect.die("unexpected connect() — the ambient password path never verify-connects"), + Effect.die("unexpected connect() — the explicit password path never verify-connects"), }); const fetchMock = Object.assign( (input: string | URL | Request, init?: RequestInit): Promise => { @@ -1319,6 +1385,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje { projectHost: "invalid", dbConnection, + flagPassword: "flag-password", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN, SUPABASE_DB_PASSWORD: "ambient-password", @@ -1332,7 +1399,7 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${targetRef}`, - password: "ambient-password", + password: "flag-password", database: "postgres", suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f69ff9b868..dd7874b6bb 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -1,9 +1,12 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; -import { Config, Crypto, Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { type CliConfigKeyOrigin } from "../config/cli-config-key.ts"; +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues, type ResolvedCliConfig } from "../config/cli-config-values.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { @@ -33,9 +36,10 @@ import { redactConnectionString, } from "./db-config.parse.ts"; import { DbConfigResolver, type DbConfigError } from "./db-config.service.ts"; -import { loadProjectEnv, readDbToml } from "./db-config.toml-read.ts"; +import { loadProjectEnvValues, readDbToml } from "./db-config.toml-read.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { rejectPasswordWithDirectTarget } from "./db-target-flags.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; @@ -201,26 +205,75 @@ const poolerConfigFrom = Effect.fnUntraced(function* ( return Option.none(); }); -// Resolve the DB password with this precedence: `--password` flag → `SUPABASE_DB_PASSWORD` -// shell env → project `.env*` value. `loadProjectEnv` already excludes shell-set keys, so the -// shell value still wins over the file. `workdir` is an explicit parameter (never -// `CommandSettings.workdir`) so callers whose real workdir has diverged from that cwd-walked -// value (e.g. `bootstrap`, after its own `process.chdir`) still resolve against the correct -// directory. -const resolveDbPassword = Effect.fnUntraced(function* ( - passwordFlag: Option.Option, +const noticedWithheldPassword = new WeakSet(); + +const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { + switch (origin.tier) { + case "flag": + return `--${origin.flag}`; + case "shell": + return `${origin.envName} (environment)`; + case "projectEnv": + return origin.file === undefined ? origin.envName : `${origin.envName} (${origin.file})`; + default: + return origin.tier; + } +}; + +const loadFailureToDbConfigError = ( + error: E, +) => + Errors.isPassthroughLoadFailure(error) + ? error + : new Errors.DbConfigLoadError({ message: error.message }); + +/** + * Resolves the database password through `CliConfigValues` (`--password` > shell env > project + * `.env*`). `workdir` is an explicit parameter (never `CommandSettings.workdir`) so callers whose + * real workdir has diverged from that cwd-walked value (e.g. `bootstrap`, after its own + * `process.chdir`) resolve against the correct directory. `explicit` is a password the caller + * already holds, such as the one `bootstrap` just set on a new project. An empty result means "mint + * a temporary login role". + */ +export const resolveLinkedPassword = Effect.fn("DbConfig.resolveLinkedPassword")(function* ( + ref: string, workdir: string, + explicit: Option.Option, + hasPasswordFlag = false, ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - const ambientPassword = yield* Config.option(Config.String("SUPABASE_DB_PASSWORD")); - return ( - Option.getOrUndefined(passwordFlag) ?? - Option.getOrUndefined(ambientPassword) ?? - projectEnv["SUPABASE_DB_PASSWORD"] ?? - "" + const debug = yield* DebugLogger; + if (Option.isSome(explicit)) { + yield* debug.debug("Using the database password supplied by the caller..."); + return explicit.value; + } + const output = yield* Output; + const resolvedConfig = yield* (yield* CliConfigValues) + .load({ workdir, projectRef: Option.some(ref) }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + const resolved = yield* resolvedConfig + .get(CliConfigKeys.linkedDb.password) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + const withheld = resolvedConfig.withheldEnv.find( + (entry) => entry.path === CliConfigKeys.linkedDb.password.path, ); + if ( + withheld !== undefined && + resolved.origin.tier !== "flag" && + !noticedWithheldPassword.has(resolvedConfig) + ) { + noticedWithheldPassword.add(resolvedConfig); + yield* output.warn( + `Not sending ${withheld.envName} to ${withheld.targetRef}: this directory is linked to ${withheld.linkedRef}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN).${ + hasPasswordFlag ? ` Pass --password to use a password for ${withheld.targetRef}.` : "" + }`, + ); + } + if (Option.isNone(resolved.value)) { + yield* debug.debug("No database password found; using a temporary login role..."); + return ""; + } + yield* debug.debug(`Using database password from ${describePasswordOrigin(resolved.origin)}...`); + return resolved.value.value; }); /** @@ -228,7 +281,7 @@ const resolveDbPassword = Effect.fnUntraced(function* ( * configured or it fails validation, so the caller can keep the original error. With a password, * uses it directly; without one, mints a temp login role and verify-connects through the pooler. * - * `workdir`/`poolerHost` are explicit parameters (see {@link resolveDbPassword}). + * `workdir`/`poolerHost` are explicit parameters (see {@link resolveLinkedPassword}). */ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ref: string, @@ -248,7 +301,6 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const debug = yield* DebugLogger; // Linked-path read: merge the `[remotes.]` override, so this matches the ref-aware read // on the main linked branch rather than validating base config. For an ad-hoc `--project-id` // ref, skip the saved workdir pooler URL because it belongs to the linked project, not @@ -286,10 +338,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( } if (Option.isNone(pooler)) return Option.none(); const poolerConn = pooler.value; - if (password.length > 0) { - yield* debug.debug("Using database password from env var..."); - return Option.some({ ...poolerConn, password }); - } + if (password.length > 0) return Option.some({ ...poolerConn, password }); // Mint a temp role; preserve Supavisor's `.` tenant suffix. const originalUser = poolerConn.user; const withRole = yield* initLoginRole(ref, poolerConn); @@ -318,13 +367,13 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio projectHost: string, poolerHost: string, dnsResolver: "native" | "https", - passwordFlag: Option.Option, + explicitPassword: Option.Option, options: { readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** * Requests the Management API pooler-config fetch on an IPv4-only network - * independent of `adHocProjectRef`'s credential/saved-URL semantics — see + * independent of `adHocProjectRef`'s saved-URL semantics — see * `DbConfigFlags.linkedProjectRef`'s doc comment. Set when the caller * supplied an explicit ref (`--project-ref`/`--project-id`) rather than * falling back to `.temp/project-ref`, so an unlinked or mismatched-tenant @@ -332,22 +381,17 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio * the "run supabase link" IPv6 error. */ readonly fetchPoolerFromApi?: boolean; + /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ + readonly hasPasswordFlag?: boolean; } = {}, ) { const { adHocProjectRef = false, resolveVaultSecrets = true, fetchPoolerFromApi = false, + hasPasswordFlag = false, } = options; - const debug = yield* DebugLogger; - // Read lazily (per invocation) rather than at layer build, so tests and - // env-substitution see the current value. For an ad-hoc `--project-id` ref, - // honor only an explicit `--password` flag and ignore the ambient - // `SUPABASE_DB_PASSWORD` (which belongs to the current workdir, not this ref), - // so we always mint a temporary login role instead of leaking it. - const dbPassword = adHocProjectRef - ? (Option.getOrUndefined(passwordFlag) ?? "") - : yield* resolveDbPassword(passwordFlag, workdir); + const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); const host = `db.${ref}.${projectHost}`; const base: PgConnInput = { host, @@ -360,10 +404,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio const reachable = yield* tcpReachable(host, DIRECT_PORT); yield* Effect.annotateCurrentSpan("db.direct_reachable", reachable); if (reachable) { - if (base.password.length > 0) { - yield* debug.debug("Using database password from env var..."); - return base; - } + if (base.password.length > 0) return base; return yield* initLoginRole(ref, base); } @@ -407,6 +448,7 @@ export const dbConfigResolverLayer = Layer.effect( const debug = yield* DebugLogger; const output = yield* Output; const dbConn = yield* DbConnection; + const configValues = yield* CliConfigValues; // `resolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions that yield their // own `FileSystem`/`Path`/`DebugLogger`/`Output`/`DbConnection` (so bootstrap can call them // directly from its own ambient context). Calling them from here would otherwise leak those @@ -419,6 +461,7 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(DebugLogger, debug), Layer.succeed(Output, output), Layer.succeed(DbConnection, dbConn), + Layer.succeed(CliConfigValues, configValues), ); // Profile context for the connect-failure suggestion. Snapshot it once and attach it to @@ -482,6 +525,7 @@ export const dbConfigResolverLayer = Layer.effect( const resolve = (flags: DbConfigFlags) => Effect.gen(function* () { + yield* rejectPasswordWithDirectTarget(flags.connType, flags.password); const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; // Config is read per branch, not unconditionally up front: the linked branch resolves // the ref first and reads the `[remotes.]`-merged config (below). A base read here @@ -490,11 +534,23 @@ export const dbConfigResolverLayer = Layer.effect( // `--db-url`/`--local` read base config, since neither merges a remote block. // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or // remote-Docker setups, defaulting to 127.0.0.1. - const localHost = yield* getHostname().pipe( + // Only env feeds the hostname, so the config file stays unread here. + const hostConfig = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + tolerateInvalid: true, + }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + const localHost = yield* getHostname(hostConfig).pipe( Effect.provideService(RuntimeInfo, runtimeInfo), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), + Effect.catchTag("CliConfigLoadError", (error) => + Effect.fail(new Errors.DbConfigLoadError({ message: error.message })), + ), ); // --db-url (direct) takes precedence. @@ -503,9 +559,9 @@ export const dbConfigResolverLayer = Layer.effect( resolveVaultSecrets, }); // The project `.env*` files populate the environment that the libpq `PG*` fallbacks - // read. Layer the project env under the shell env (`loadProjectEnv` already excludes + // read. Layer the project env under the shell env (`loadProjectEnvValues` already excludes // shell-set keys, so the shell still wins) and feed it to the parser. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const conn = yield* parseConnectionString( flags.dbUrl.value, yield* layeredParseEnv(projectEnv), @@ -581,15 +637,16 @@ export const dbConfigResolverLayer = Layer.effect( cliSettings.projectHost, cliSettings.poolerHost, flags.dnsResolver, - flags.password ?? Option.none(), + Option.none(), { adHocProjectRef: flags.adHocProjectRef ?? false, resolveVaultSecrets, // An explicit ref (the eight `db` commands' `--project-ref`, or // `gen types --project-id`) independently unlocks the Management API - // pooler fetch, regardless of `adHocProjectRef`'s credential semantics + // pooler fetch, regardless of `adHocProjectRef`'s saved-URL semantics // — see `DbConfigFlags.linkedProjectRef`'s doc comment. fetchPoolerFromApi: Option.isSome(flags.linkedProjectRef ?? Option.none()), + hasPasswordFlag: flags.password !== undefined, }, ); // The linked-project telemetry cache (GET /v1/projects/{ref}) is not issued here: @@ -646,9 +703,12 @@ export const dbConfigResolverLayer = Layer.effect( const ref = refOpt.value; if (!PROJECT_REF_PATTERN.test(ref)) return Option.none(); const adHocProjectRef = flags.adHocProjectRef ?? false; - const password = adHocProjectRef - ? (Option.getOrUndefined(flags.password ?? Option.none()) ?? "") - : yield* resolveDbPassword(flags.password ?? Option.none(), cliSettings.workdir); + const password = yield* resolveLinkedPassword( + ref, + cliSettings.workdir, + Option.none(), + flags.password !== undefined, + ); // Container-fallback: fetch the primary pooler config from the Management API when // no `.temp/pooler-url` is saved. return yield* resolvePoolerConn( @@ -680,6 +740,7 @@ export const dbConfigResolverLayer = Layer.effect( return DbConfigResolver.of({ resolve: (flags) => resolve(flags).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.tap((r) => Effect.annotateCurrentSpan("db.is_local", r.isLocal)), Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) })), Effect.withSpan("DbConfig.resolve", { @@ -688,6 +749,7 @@ export const dbConfigResolverLayer = Layer.effect( ), resolvePoolerFallback: (flags) => resolvePoolerFallback(flags).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.tap((pooler) => Effect.annotateCurrentSpan("db.pooler.found", Option.isSome(pooler)), ), diff --git a/apps/cli/src/command-internal/db-config.parse.unit.test.ts b/apps/cli/src/command-internal/db-config.parse.unit.test.ts index 47a6221f31..e7fd08cc5d 100644 --- a/apps/cli/src/command-internal/db-config.parse.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.parse.unit.test.ts @@ -700,6 +700,18 @@ layer(parseTestLayer)("injected env lookup (project dotenv parity)", (it) => { }), ); + it.effect("prefers a shell var over the project .env and falls back for unset names", () => + withConfigEnv( + { PGHOST: "shell-host" }, + Effect.gen(function* () { + const env = yield* layeredParseEnv({ PGHOST: "project-host", PGPORT: "6543" }); + expect(env("PGHOST")).toBe("shell-host"); + expect(env("PGPORT")).toBe("6543"); + expect(env("PGUSER")).toBeUndefined(); + }), + ), + ); + it.effect("keeps a set-but-empty shell var ahead of the project .env (layeredParseEnv)", () => withConfigEnv( { PGHOST: "" }, diff --git a/apps/cli/src/command-internal/db-config.password.integration.test.ts b/apps/cli/src/command-internal/db-config.password.integration.test.ts new file mode 100644 index 0000000000..80cf4676a5 --- /dev/null +++ b/apps/cli/src/command-internal/db-config.password.integration.test.ts @@ -0,0 +1,225 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { flagInput } from "../../tests/helpers/config-values-layer.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { resolveLinkedPassword } from "./db-config.layer.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; + +const LINKED = "linkedprojectrefabcd"; +const TARGET = "targetprojectrefabcd"; + +const workdir = Effect.fnUntraced(function* ( + opts: { readonly linkedRef?: string; readonly dotenv?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "db-config-password-" }); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + if (opts.linkedRef !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), opts.linkedRef); + } + if (opts.dotenv !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), opts.dotenv); + } + return dir; +}); + +const withWorkdir = ( + opts: { readonly linkedRef?: string; readonly dotenv?: string }, + use: (dir: string) => Effect.Effect, +) => Effect.flatMap(workdir(opts), use).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const run = ( + effect: Effect.Effect, + opts: { + readonly env?: Record; + readonly flagPassword?: string; + } = {}, +) => { + const out = mockOutput(); + const debugLines: Array = []; + const layer = Layer.mergeAll( + out.layer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), + ), + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs( + opts.flagPassword === undefined + ? [] + : [flagInput("linkedDb.password", "password", opts.flagPassword)], + ), + ), + ), + ), + ), + Layer.succeed(DebugLogger, { + debug: (message) => Effect.sync(() => void debugLines.push(message)), + http: () => Effect.void, + }), + ); + return effect.pipe( + Effect.provide(layer), + Effect.map((result) => ({ + result, + stderr: out.stderrText, + warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), + debugLines, + })), + ); +}; + +const ENV = { SUPABASE_DB_PASSWORD: "env-password" }; + +describe("resolveLinkedPassword", () => { + it.effect("uses the env password when the target is the linked project", () => + withWorkdir({ linkedRef: TARGET }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ result, warnings, debugLines }) => + Effect.sync(() => { + expect(result).toBe("env-password"); + expect(warnings).toEqual([]); + expect(debugLines).toEqual([ + "Using database password from SUPABASE_DB_PASSWORD (environment)...", + ]); + }), + ), + ), + ), + ); + + it.effect("uses the env password when the workdir is not linked", () => + withWorkdir({}, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ result, warnings }) => + Effect.sync(() => { + expect(result).toBe("env-password"); + expect(warnings).toEqual([]); + }), + ), + ), + ), + ); + + it.effect( + "ignores the env password for a project other than the linked one and says so once", + () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run( + Effect.gen(function* () { + const first = yield* resolveLinkedPassword(TARGET, dir, Option.none(), true); + const second = yield* resolveLinkedPassword(TARGET, dir, Option.none(), true); + return [first, second]; + }), + { env: ENV }, + ).pipe( + Effect.tap(({ result, warnings, debugLines }) => + Effect.sync(() => { + expect(result).toEqual(["", ""]); + expect(warnings).toEqual([ + `Not sending SUPABASE_DB_PASSWORD to ${TARGET}: this directory is linked to ${LINKED}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for ${TARGET}.`, + ]); + expect(debugLines).toEqual([ + "No database password found; using a temporary login role...", + "No database password found; using a temporary login role...", + ]); + }), + ), + ), + ), + ); + + it.effect("omits the --password sentence for commands without a --password binding", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { env: ENV }).pipe( + Effect.tap(({ warnings }) => + Effect.sync(() => { + expect(warnings).toEqual([ + `Not sending SUPABASE_DB_PASSWORD to ${TARGET}: this directory is linked to ${LINKED}. Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN).`, + ]); + }), + ), + ), + ), + ); + + it.effect("ignores a project .env password for a project other than the linked one", () => + withWorkdir({ linkedRef: LINKED, dotenv: "SUPABASE_DB_PASSWORD=dotenv-password\n" }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none())).pipe( + Effect.tap(({ result, warnings }) => + Effect.sync(() => { + expect(result).toBe(""); + expect(warnings).toHaveLength(1); + }), + ), + ), + ), + ); + + it.effect("uses the project .env password for the linked project", () => + withWorkdir({ linkedRef: TARGET, dotenv: "SUPABASE_DB_PASSWORD=dotenv-password\n" }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none())).pipe( + Effect.tap(({ result, debugLines }) => + Effect.sync(() => { + expect(result).toBe("dotenv-password"); + expect(debugLines[0]).toContain("SUPABASE_DB_PASSWORD ("); + expect(debugLines[0]).toContain(".env"); + }), + ), + ), + ), + ); + + it.effect("uses the --password flag for a foreign project without a notice", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { + env: ENV, + flagPassword: "flag-password", + }).pipe( + Effect.tap(({ result, stderr, debugLines }) => + Effect.sync(() => { + expect(result).toBe("flag-password"); + expect(debugLines).toEqual(["Using database password from --password..."]); + expect(stderr).not.toContain("SUPABASE_DB_PASSWORD"); + }), + ), + ), + ), + ); + + it.effect("prefers --password over the env password for the linked project", () => + withWorkdir({ linkedRef: TARGET }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.none()), { + env: ENV, + flagPassword: "flag-password", + }).pipe(Effect.tap(({ result }) => Effect.sync(() => expect(result).toBe("flag-password")))), + ), + ); + + it.effect("returns a caller-supplied password without loading config", () => + withWorkdir({ linkedRef: LINKED }, (dir) => + run(resolveLinkedPassword(TARGET, dir, Option.some("created-password")), { + env: ENV, + }).pipe( + Effect.tap(({ result, stderr }) => + Effect.sync(() => { + expect(result).toBe("created-password"); + expect(stderr).toBe(""); + }), + ), + ), + ), + ); +}); diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index f4e5bdfadf..be1f96ffeb 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -8,7 +8,7 @@ import type { ProjectRefNotLinkedError, } from "../config/project-ref.errors.ts"; import type { ProfileLoadError } from "./profile-load.ts"; -import type { ProjectRefReadError } from "./temp-paths.ts"; +import type { ProjectRefReadError } from "../shared/config/temp-paths.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; import type { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import type { @@ -23,14 +23,22 @@ import type { DbConfigPoolerLoginError, DbConfigUnbanNetworkError, DbConfigUnbanStatusError, + DbPasswordFlagsError, } from "./db-config.errors.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "./db-config.types.ts"; +import type { + CliConfigFlagConflictError, + CliConfigValueError, +} from "../config/cli-config.errors.ts"; /** Every error the resolver can raise across the direct / local / linked paths. */ export type DbConfigError = | Config.ConfigError | DbConfigParseUrlError + | DbPasswordFlagsError | DbConfigLoadError + | CliConfigValueError + | CliConfigFlagConflictError | LocalDbRunningError | ProjectRefNotLinkedError | InvalidProjectRefError diff --git a/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts new file mode 100644 index 0000000000..253d01fd99 --- /dev/null +++ b/apps/cli/src/command-internal/db-config.toml-read.precedence.unit.test.ts @@ -0,0 +1,242 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Path, Result } from "effect"; + +import { ConfigEnvPins } from "../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../tests/helpers/config-goldens.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { checkDbToml } from "./db-config.toml-read.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const OTHER_REF = "zyxwvutsrqponmlkjihg"; +const GOLDEN_DIR = "./testdata/config-precedence/db-toml"; + +const BASE_CONFIG = `project_id = "golden-base" + +[api] +schemas = ["public", "base_api"] + +[db] +port = 55001 +shadow_port = 55002 + +[db.migrations] +enabled = true +schema_paths = ["./schemas/*.sql"] + +[db.seed] +enabled = true +sql_paths = ["./seeds/*.sql"] + +[auth] +enabled = true + +[storage] +enabled = true + +[realtime] +enabled = true + +[experimental.pgdelta] +enabled = false +declarative_schema_path = "base-declarative" +`; + +const remoteBlock = (projectId: string) => ` +[remotes.prod] +project_id = "${projectId}" + +[remotes.prod.db.migrations] +enabled = false + +[remotes.prod.auth] +enabled = false + +[remotes.prod.experimental.pgdelta] +declarative_schema_path = "remote-declarative" +`; + +const SHELL_OVERRIDES = { + SUPABASE_DB_MIGRATIONS_ENABLED: "false", + SUPABASE_DB_PORT: "56001", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_DB_SEED_ENABLED: "false", +}; + +const DOTENV_OVERRIDES = [ + "SUPABASE_DB_MIGRATIONS_ENABLED=false", + "SUPABASE_DB_PORT=57001", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_DB_SEED_ENABLED=false", + "", +].join("\n"); + +const CONFLICTING_SHELL = { + SUPABASE_DB_MIGRATIONS_ENABLED: "true", + SUPABASE_AUTH_ENABLED: "true", + SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH: "shell-declarative", + SUPABASE_DB_SEED_ENABLED: "true", + SUPABASE_DB_PORT: "56001", + SUPABASE_API_SCHEMAS: "public,shell_api", +}; + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; + readonly ref?: string; + readonly expectedProjectId?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "a-base-only", + name: "base config with no env and no remote resolves its own values", + config: BASE_CONFIG, + ref: TARGET_REF, + }, + { + golden: "b-matched-remote", + name: "matched remote overrides migrations, auth and pgdelta and defaults seeding off", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + ref: TARGET_REF, + }, + { + golden: "b2-remote-block-without-ref", + name: "a remote block is ignored when no target ref is supplied", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + }, + { + golden: "c-shell-env", + name: "shell env overrides base config for migrations, port, api schemas and seed", + config: BASE_CONFIG, + shellEnv: SHELL_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "d-project-dotenv", + name: "supabase/.env overrides base config for migrations, port, api schemas and seed", + config: BASE_CONFIG, + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "d2-shell-beats-dotenv", + name: "shell env beats supabase/.env for the same key", + config: BASE_CONFIG, + shellEnv: SHELL_OVERRIDES, + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "e-remote-beats-shell", + name: "shell env beats a matched remote for db.migrations.enabled, auth.enabled, pgdelta path and seed", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + shellEnv: CONFLICTING_SHELL, + ref: TARGET_REF, + }, + { + golden: "e2-remote-beats-dotenv", + name: "supabase/.env beats a matched remote for db.migrations.enabled and seed", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + dotenv: DOTENV_OVERRIDES, + ref: TARGET_REF, + }, + { + golden: "f-remote-matched-by-env-project-id", + name: "SUPABASE_REMOTES_PROD_PROJECT_ID selects the remote block for the target ref", + config: BASE_CONFIG + remoteBlock(OTHER_REF), + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + ref: TARGET_REF, + }, + { + golden: "f2-remote-env-match-project-id-is-block-literal", + name: "remote matched via SUPABASE_REMOTES_PROD_PROJECT_ID yields projectId equal to the block's TOML literal, not the target ref", + config: BASE_CONFIG + remoteBlock(OTHER_REF), + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + ref: TARGET_REF, + expectedProjectId: OTHER_REF, + }, + { + golden: "g-captcha-secret-from-env-only", + name: "captcha enabled in TOML passes validation with the secret only in SUPABASE_AUTH_CAPTCHA_SECRET", + config: `${BASE_CONFIG} +[auth.captcha] +enabled = true +provider = "hcaptcha" +`, + shellEnv: { SUPABASE_AUTH_CAPTCHA_SECRET: "fake-captcha-secret" }, + ref: TARGET_REF, + }, + { + golden: "h-storage-realtime-env-disable", + name: "SUPABASE_STORAGE_ENABLED and SUPABASE_REALTIME_ENABLED disable the baseline storage and realtime flags", + config: BASE_CONFIG, + shellEnv: { SUPABASE_STORAGE_ENABLED: "false", SUPABASE_REALTIME_ENABLED: "false" }, + ref: TARGET_REF, + }, + { + golden: "i-project-id-env-with-matched-remote", + name: "SUPABASE_PROJECT_ID beats a matched remote project_id", + config: BASE_CONFIG + remoteBlock(TARGET_REF), + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + ref: TARGET_REF, + }, + { + golden: "i2-project-id-env-without-remote", + name: "SUPABASE_PROJECT_ID overrides the base project_id when no remote matches", + config: BASE_CONFIG, + shellEnv: { SUPABASE_PROJECT_ID: "shell-project-id" }, + ref: TARGET_REF, + }, +]; + +const tempRoot = useTempWorkdir("db-toml-golden-"); + +describe("db toml reader precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.effect(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const shell = fixture.shellEnv ?? {}; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(shell); + + const outcome = yield* checkDbToml(fs, path, workdir, fixture.ref).pipe( + Effect.provide(cliConfigValuesTestLayer), + Effect.provideService(ConfigEnvPins, shell), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(shell, { preserveEmptyStrings: true }), + ), + Effect.result, + ); + + const snapshot = Result.isSuccess(outcome) + ? { ok: outcome.success } + : { error: { tag: outcome.failure._tag, message: outcome.failure.message } }; + if (fixture.expectedProjectId !== undefined) { + ctx + .expect(Result.isSuccess(outcome) ? outcome.success.projectId : undefined) + .toBe(fixture.expectedProjectId); + } + yield* Effect.promise(() => + ctx + .expect(goldenJson(snapshot, { [workdir]: "" })) + .toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index d36040ef84..954527b909 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -1,18 +1,19 @@ -import { normalizeDeprecatedOrioleDBVersion } from "@supabase/config/internal"; -import { Config, Effect, Match, type FileSystem, Option, type Path } from "effect"; -import * as SmolToml from "smol-toml"; +import { Effect, FileSystem, Option, Path } from "effect"; +import type { CliConfigFlagDeclaration } from "../config/cli-config-flags.ts"; +import type { CliConfigKey } from "../config/cli-config-key.ts"; +import { envReferenceNames } from "../config/cli-config-document.ts"; +import { CliConfigKeys, cliConfigFamilyKey, cliConfigRegistry } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { loadCliProjectEnvFiles } from "../shared/config/cli-config-env.ts"; import { - PROJECT_REF_PATTERN, type AnalyticsInput, type AuthInput, type CaptchaInput, type ConfigValidationInput, - type DbInput, emailContentPathReadErrorMessage, type ExperimentalInput, type HookInput, type MfaFactorInput, - parseBoolLiteral, type PasskeyInput, resolveEmailTemplateContentPath, resolveSigningKeysPath, @@ -20,14 +21,16 @@ import { signingKeysReadErrorMessage, type SmtpInput, type ThirdPartyInput, + validateAuthExternalProviders, + validateAuthSmsProviders, validateResolvedConfig, } from "./config-validate.ts"; -import { DbConfigLoadError } from "./db-config.errors.ts"; +import { DbConfigLoadError, isPassthroughLoadFailure } from "./db-config.errors.ts"; import { recordOrioleDbTelemetry, selectsOrioleDb } from "./db-image.ts"; -import { parseDotEnv } from "./dotenv.ts"; -import { strToArr } from "./local-config-values.ts"; +import { resolveAuthExternalProviders } from "./local-config-values.ts"; import { ramInBytes } from "./size-units.ts"; -import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; +import { decryptSecret, isEncryptedSecret } from "../shared/config/vault-decrypt.ts"; /** Resolves a config `env(VAR)` reference: shell env first, then project `.env`. */ type EnvLookup = (name: string) => string | undefined; @@ -52,8 +55,8 @@ export interface DbTomlValues { * part of the config schema. */ readonly poolerConnectionString: Option.Option; - /** top-level `project_id`, used to name the local docker network. */ - readonly projectId: Option.Option; + /** The resolved, sanitized `project_id` (flag, env, config, then the workdir name). */ + readonly projectId: string; /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** @@ -106,13 +109,6 @@ export interface DbTomlValues { * (`Loading config override: [remotes.]` line), else `undefined`. */ readonly appliedRemote: string | undefined; - /** - * The config keys the matched remote block contributed at override tier — - * see {@link RemoteOverride.remoteOverrideKeys}. Exposed so a separate config - * read for the same linked ref can apply the identical remote-over-env - * precedence without re-deriving this set. Empty when no remote matched. - */ - readonly remoteOverrideKeys: ReadonlySet; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -165,14 +161,6 @@ export interface PgDeltaTomlConfig { readonly formatOptions: Option.Option; } -const DEFAULT_PORT = 54322; -const DEFAULT_SHADOW_PORT = 54320; -const DEFAULT_MAJOR_VERSION = 17; -const DEFAULT_PASSWORD = "postgres"; -const DEFAULT_API_SCHEMAS = ["public", "graphql_public"] as const; -/** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ -const DEFAULT_DENO_VERSION = 2; - /** Default declarative schema dir. */ const DEFAULT_DECLARATIVE_DIR_SEGMENTS = ["supabase", "schemas"] as const; @@ -184,453 +172,14 @@ function asRecord(value: unknown): RawDoc | undefined { : undefined; } -/** Recursively merge `override` over `base` (nested tables merge, scalars/arrays - * replace), per-key. */ -function deepMergeDoc(base: RawDoc, override: RawDoc): RawDoc { - const out: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - const baseValue = out[key]; - const baseRecord = asRecord(baseValue); - const overrideRecord = asRecord(value); - out[key] = - baseRecord !== undefined && overrideRecord !== undefined - ? deepMergeDoc(baseRecord, overrideRecord) - : value; - } - return out; -} - -/** - * Merge the `[remotes.]` block whose `project_id` equals `ref` over the base - * config. The block key name is only used for diagnostics; the match is on - * `project_id`. - */ -interface RemoteOverride { - readonly doc: RawDoc | undefined; - /** - * The name of the matched `[remotes.]` block whose `project_id` equals the - * resolved ref, or `undefined` when no block matched. Callers echo the - * `Loading config override: [remotes.]` stderr line from this. - */ - readonly appliedRemote?: string; - /** - * The config keys the matched remote block contributed at override tier; - * each must outrank the matching `SUPABASE_*` env override. Holds every key - * in {@link ENV_OVERRIDABLE_KEYS} the block supplies, plus `db.seed.enabled` - * (always forced). - */ - readonly remoteOverrideKeys: ReadonlySet; -} - -const remoteProjectIdEnvName = (name: string): string => - `SUPABASE_REMOTES_${name.toUpperCase()}_PROJECT_ID`; - -/** - * The `project_id` of a `[remotes.]` block for matching/duplicate - * detection: `SUPABASE_REMOTES__PROJECT_ID` wins when non-empty, else - * the raw (unexpanded) TOML literal. Validation instead uses the expanded - * value — see {@link resolveValidatedRemoteProjectId}. - */ -function resolveRemoteProjectId( - name: string, - block: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const fromEnv = lookup(remoteProjectIdEnvName(name)); - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const literal = block?.["project_id"]; - return typeof literal === "string" ? literal : undefined; -} - -/** - * The `project_id` of a `[remotes.]` block for validation: the same - * override precedence as {@link resolveRemoteProjectId}, but with `env(...)` - * expanded (an unset `env(...)` stays literal and fails the ref pattern). - */ -function resolveValidatedRemoteProjectId( - name: string, - block: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const fromEnv = lookup(remoteProjectIdEnvName(name)); - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const literal = block?.["project_id"]; - return typeof literal === "string" ? expandEnv(literal, lookup) : undefined; -} - -/** - * Every dotted config key this reader resolves with a `SUPABASE_*` env override. - * When a matched `[remotes.*]` block supplies any of these, the block value - * must beat the matching env override. - */ -const ENV_OVERRIDABLE_KEYS = [ - "project_id", - "api.schemas", - "db.port", - "db.shadow_port", - "db.major_version", - "db.migrations.enabled", - "db.migrations.schema_paths", - "db.seed.enabled", - "db.seed.sql_paths", - "auth.enabled", - "api.enabled", - "edge_runtime.deno_version", - "experimental.webhooks.enabled", - "experimental.pgdelta.enabled", - "experimental.pgdelta.declarative_schema_path", - "experimental.pgdelta.format_options", - "api.auto_expose_new_tables", - "analytics.enabled", - "analytics.backend", - "analytics.gcp_project_id", - "analytics.gcp_project_number", - "analytics.gcp_jwt_path", - "db.orioledb_version", - "experimental.s3_host", - "experimental.s3_region", - "experimental.s3_access_key", - "experimental.s3_secret_key", - "realtime.enabled", - "realtime.ip_version", - "realtime.max_header_length", - "storage.enabled", - "storage.file_size_limit", - "db.health_timeout", - "db.settings.effective_cache_size", - "db.settings.logical_decoding_work_mem", - "db.settings.maintenance_work_mem", - "db.settings.max_connections", - "db.settings.max_locks_per_transaction", - "db.settings.max_parallel_maintenance_workers", - "db.settings.max_parallel_workers", - "db.settings.max_parallel_workers_per_gather", - "db.settings.max_replication_slots", - "db.settings.max_slot_wal_keep_size", - "db.settings.max_standby_archive_delay", - "db.settings.max_standby_streaming_delay", - "db.settings.max_wal_size", - "db.settings.max_wal_senders", - "db.settings.max_worker_processes", - "db.settings.session_replication_role", - "db.settings.shared_buffers", - "db.settings.statement_timeout", - "db.settings.track_activity_query_size", - "db.settings.track_commit_timestamp", - "db.settings.wal_keep_size", - "db.settings.wal_sender_timeout", - "db.settings.work_mem", - "db.network_restrictions.enabled", - "db.root_key", - "api.port", - "api.tls.enabled", - "api.tls.cert_path", - "api.tls.key_path", - "api.external_url", - "auth.jwt_secret", - "auth.jwt_expiry", - "auth.site_url", - "auth.anon_key", - "auth.service_role_key", - "auth.signing_keys_path", - "auth.external_url", - "auth.third_party.firebase.enabled", - "auth.third_party.firebase.project_id", - "auth.third_party.auth0.enabled", - "auth.third_party.auth0.tenant", - "auth.third_party.auth0.tenant_region", - "auth.third_party.aws_cognito.enabled", - "auth.third_party.aws_cognito.user_pool_id", - "auth.third_party.aws_cognito.user_pool_region", - "auth.third_party.clerk.enabled", - "auth.third_party.clerk.domain", - "auth.third_party.workos.enabled", - "auth.third_party.workos.issuer_url", - "auth.jwt_issuer", - "auth.additional_redirect_urls", - "studio.enabled", - "studio.port", - "local_smtp.enabled", - "local_smtp.port", - "auth.enable_signup", - "auth.enable_anonymous_sign_ins", - "auth.enable_refresh_token_rotation", - "auth.refresh_token_reuse_interval", - "auth.enable_manual_linking", - "auth.minimum_password_length", - "auth.password_requirements", - "auth.passkey.enabled", - "auth.webauthn.rp_id", - "auth.webauthn.rp_origins", - "auth.hook.mfa_verification_attempt.enabled", - "auth.hook.mfa_verification_attempt.uri", - "auth.hook.mfa_verification_attempt.secrets", - "auth.hook.password_verification_attempt.enabled", - "auth.hook.password_verification_attempt.uri", - "auth.hook.password_verification_attempt.secrets", - "auth.hook.custom_access_token.enabled", - "auth.hook.custom_access_token.uri", - "auth.hook.custom_access_token.secrets", - "auth.hook.send_sms.enabled", - "auth.hook.send_sms.uri", - "auth.hook.send_sms.secrets", - "auth.hook.send_email.enabled", - "auth.hook.send_email.uri", - "auth.hook.send_email.secrets", - "auth.hook.before_user_created.enabled", - "auth.hook.before_user_created.uri", - "auth.hook.before_user_created.secrets", - "auth.mfa.totp.enroll_enabled", - "auth.mfa.totp.verify_enabled", - "auth.mfa.phone.enroll_enabled", - "auth.mfa.phone.verify_enabled", - "auth.mfa.phone.otp_length", - "auth.mfa.web_authn.enroll_enabled", - "auth.mfa.web_authn.verify_enabled", - "auth.mfa.max_enrolled_factors", - "auth.mfa.phone.template", - "auth.mfa.phone.max_frequency", - "auth.captcha.enabled", - "auth.captcha.provider", - "auth.captcha.secret", - "auth.email.smtp.enabled", - "auth.email.smtp.port", - "auth.email.smtp.pass", - "auth.email.smtp.host", - "auth.email.smtp.user", - "auth.email.smtp.admin_email", - "auth.email.smtp.sender_name", - "auth.email.enable_signup", - "auth.email.double_confirm_changes", - "auth.email.enable_confirmations", - "auth.email.secure_password_change", - "auth.email.otp_length", - "auth.email.otp_expiry", - "auth.email.max_frequency", - "auth.sms.enable_signup", - "auth.sms.enable_confirmations", - "auth.sms.twilio.enabled", - "auth.sms.twilio.auth_token", - "auth.sms.twilio_verify.enabled", - "auth.sms.twilio_verify.auth_token", - "auth.sms.messagebird.enabled", - "auth.sms.messagebird.access_key", - "auth.sms.textlocal.enabled", - "auth.sms.textlocal.api_key", - "auth.sms.vonage.enabled", - "auth.sms.vonage.api_secret", - "auth.sms.twilio.account_sid", - "auth.sms.twilio.message_service_sid", - "auth.sms.twilio_verify.account_sid", - "auth.sms.twilio_verify.message_service_sid", - "auth.sms.messagebird.originator", - "auth.sms.textlocal.sender", - "auth.sms.vonage.from", - "auth.sms.vonage.api_key", - "auth.sms.template", - "auth.sms.max_frequency", - "auth.publishable_key", - "auth.secret_key", - "studio.openai_api_key", - "studio.api_url", -] as const; - -/** - * `auth.external.` is a map keyed by arbitrary provider name, so these - * per-provider leaves can't be enumerated in {@link ENV_OVERRIDABLE_KEYS} — - * {@link applyRemoteOverride} tracks them dynamically instead. - */ -const AUTH_EXTERNAL_PROVIDER_FIELDS = [ - "enabled", - "client_id", - "secret", - "url", - "redirect_uri", - "skip_nonce_check", - "email_optional", -] as const; - -/** - * `auth.email.template.`/`auth.email.notification.` are the same - * arbitrarily-keyed shape as `auth.external.` above, tracked dynamically - * in {@link applyRemoteOverride} rather than enumerated here. - */ -const AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as const; - -/** {@link AUTH_EMAIL_TEMPLATE_FIELDS}'s notification-section sibling — same fields, plus `enabled`. */ -const AUTH_EMAIL_NOTIFICATION_FIELDS = ["enabled", "subject", "content_path", "content"] as const; - -/** - * Every literal member of {@link ENV_OVERRIDABLE_KEYS}, plus the dotted-key - * patterns for the dynamically-keyed families {@link applyRemoteOverride} - * tracks separately. Every `remoteWins(...)` call site is typed against this - * union, so a typo'd dotted key is a compile error instead of a - * silently-always-false gate. - */ -export type RemoteOverridableKey = - | (typeof ENV_OVERRIDABLE_KEYS)[number] - | `auth.external.${string}.${(typeof AUTH_EXTERNAL_PROVIDER_FIELDS)[number]}` - | `auth.email.template.${string}.${(typeof AUTH_EMAIL_TEMPLATE_FIELDS)[number]}` - | `auth.email.notification.${string}.${(typeof AUTH_EMAIL_NOTIFICATION_FIELDS)[number]}`; - -/** - * Hoists the `remoteOverrideKeys.has(key)` closure duplicated across several - * resolvers into one helper, typed against {@link RemoteOverridableKey} so a - * typo'd key is a compile error. - */ -export function makeRemoteWins(keys: ReadonlySet): (key: RemoteOverridableKey) => boolean { - return (key) => keys.has(key); -} - -/** Whether `block` provides a value at the dotted `key` path (scalar, array, or sub-table). */ -function blockProvidesKey(block: RawDoc, key: string): boolean { - let current: unknown = block; - for (const segment of key.split(".")) { - const record = asRecord(current); - if (record === undefined) return false; - current = record[segment]; - } - return current !== undefined; -} - -function applyRemoteOverride( - doc: RawDoc | undefined, - ref: string, - lookup: EnvLookup, -): RemoteOverride { - const remotes = asRecord(doc?.["remotes"]); - if (doc === undefined || remotes === undefined) return { doc, remoteOverrideKeys: new Set() }; - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - if (block === undefined) continue; - // Matches on the raw (env override > unexpanded TOML literal) project_id lookup, so - // blocks are selected before any `env(...)` literal is expanded. - if (resolveRemoteProjectId(name, block, lookup) === ref) { - const merged = deepMergeDoc(doc, block); - const blockSeed = asRecord(asRecord(block["db"])?.["seed"]); - // Record every env-overridable key the block supplies, so the resolution below - // suppresses the matching `SUPABASE_*` override. - const remoteOverrideKeys = new Set(); - for (const key of ENV_OVERRIDABLE_KEYS) { - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - // `auth.external.` is dynamically keyed (see AUTH_EXTERNAL_PROVIDER_FIELDS), - // so flatten whichever provider names this block actually supplies. - const externalBlock = asRecord(asRecord(block["auth"])?.["external"]); - if (externalBlock !== undefined) { - for (const providerName of Object.keys(externalBlock)) { - for (const field of AUTH_EXTERNAL_PROVIDER_FIELDS) { - const key = `auth.external.${providerName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - // Same dynamically-keyed handling for `auth.email.template.`/`notification.`. - const emailBlock = asRecord(block["auth"])?.["email"]; - const emailTemplateBlock = asRecord(asRecord(emailBlock)?.["template"]); - if (emailTemplateBlock !== undefined) { - for (const templateName of Object.keys(emailTemplateBlock)) { - for (const field of AUTH_EMAIL_TEMPLATE_FIELDS) { - const key = `auth.email.template.${templateName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - const emailNotificationBlock = asRecord(asRecord(emailBlock)?.["notification"]); - if (emailNotificationBlock !== undefined) { - for (const notificationName of Object.keys(emailNotificationBlock)) { - for (const field of AUTH_EMAIL_NOTIFICATION_FIELDS) { - const key = `auth.email.notification.${notificationName}.${field}`; - if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); - } - } - } - // `db.seed.enabled` is always override-tier: either the block set it, or it's - // forced false below when omitted — so env never overrides it on a matched-remote run. - remoteOverrideKeys.add("db.seed.enabled"); - if (blockSeed?.["enabled"] === undefined) { - return { - doc: deepMergeDoc(merged, { db: { seed: { enabled: false } } }), - appliedRemote: name, - remoteOverrideKeys, - }; - } - return { doc: merged, appliedRemote: name, remoteOverrideKeys }; - } - } - return { doc, remoteOverrideKeys: new Set() }; -} - -/** - * Config load aborts when two `[remotes.*]` blocks declare the same - * `project_id`. Returns the conflicting pair (current + prior block name) or - * `undefined`. - */ -function findDuplicateRemoteProjectId( - doc: RawDoc | undefined, - lookup: EnvLookup, -): { readonly name: string; readonly other: string } | undefined { - const remotes = asRecord(doc?.["remotes"]); - if (remotes === undefined) return undefined; - const seen = new Map(); - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - // Same raw project_id lookup used for block matching, so dedup uses the same identity. - const projectId = resolveRemoteProjectId(name, block, lookup); - if (projectId === undefined) continue; - const prior = seen.get(projectId); - if (prior !== undefined) return { name, other: prior }; - seen.set(projectId, name); - } - return undefined; -} - -/** - * Rejects any `[remotes.]` whose `project_id` is not a valid project - * ref, on every config load — so a malformed or missing remote `project_id` - * fails even local/direct commands before touching the database. Returns the - * first offending block name (object order) or `undefined`. - */ -function findInvalidRemoteProjectId( - doc: RawDoc | undefined, - lookup: EnvLookup, -): string | undefined { - const remotes = asRecord(doc?.["remotes"]); - if (remotes === undefined) return undefined; - for (const name of Object.keys(remotes)) { - const block = asRecord(remotes[name]); - // Validates the expanded project_id; an unset `env(...)` stays literal and still - // fails the ref pattern. (Block matching/dedup above use the raw literal.) - const projectId = resolveValidatedRemoteProjectId(name, block, lookup); - if (typeof projectId !== "string" || !PROJECT_REF_PATTERN.test(projectId)) { - return name; - } - } - return undefined; -} - const ENV_PATTERN = /^env\((.*)\)$/; -/** The variable name inside an `env(VAR)` reference, or `undefined` for any other string. */ -export function envRefName(value: string): string | undefined { +function envRefName(value: string): string | undefined { const matches = ENV_PATTERN.exec(value); return matches === null ? undefined : (matches[1] ?? ""); } -export function envRefNames(node: unknown): ReadonlyArray { - if (typeof node === "string") { - const name = envRefName(node); - return name === undefined ? [] : [name]; - } - return typeof node === "object" && node !== null ? Object.values(node).flatMap(envRefNames) : []; -} - -/** - * The substitution rule for an `env(VAR)` reference: the resolved value wins only when it - * is set and non-empty; otherwise the `env(VAR)` literal is preserved unchanged. Shared - * with the inspect report reader, which resolves the name through Effect's `Config`. - */ -export function envRefValue(literal: string, resolved: string | undefined): string { +function envRefValue(literal: string, resolved: string | undefined): string { return resolved !== undefined && resolved.length > 0 ? resolved : literal; } @@ -647,286 +196,22 @@ function expandEnv(value: string, lookup: (name: string) => string | undefined): return envRefValue(value, lookup(name)); } -/** `[db]` ports are integers in 0-65535. */ -const MAX_PORT = 65535; - /** - * Resolve a `[db]` port field: the TOML value must be an integer in 0-65535, and a - * quoted `env(VAR)` reference is expanded first, then parsed as the port. - * Resolution rules: - * - * - **Omitted** (`undefined`) → the schema default. - * - **Present and resolves to a valid port** (a plain integer in range, or an - * `env(VAR)` string that expands to one) → that value. - * - **Present but invalid** (non-numeric, negative, out of range, or an - * unresolved `env(VAR)`) → `undefined`, signalling the caller to abort with - * `DbConfigLoadError` rather than silently defaulting and running - * against the default local database while hiding a broken config. + * The project `.env*` values the shell does not already set, for handlers that resolve global + * flags such as `--yes` against them. */ -function resolvePort(value: unknown, fallback: number, lookup: EnvLookup): number | undefined { - if (value === undefined) return fallback; - if (typeof value === "number") { - return Number.isInteger(value) && value >= 0 && value <= MAX_PORT ? value : undefined; - } - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - if (/^\d+$/.test(expanded)) { - const parsed = Number(expanded); - if (parsed <= MAX_PORT) return parsed; - } - } - return undefined; -} - -/** - * Resolve an optional integer config field (e.g. `db.major_version`): a - * quoted `env(VAR)` reference is expanded, then the result decodes into a - * `uint`, which strictly rejects a non-integer string like `17foo` rather - * than truncating it. Returns the parsed integer, `"absent"` when the field - * is omitted (caller uses the default), or `"invalid"` when present but not a - * whole non-negative integer (caller fails the load rather than silently - * defaulting and hiding a broken config). - */ -function resolveConfigInt(value: unknown, lookup: EnvLookup): number | "absent" | "invalid" { - if (value === undefined) return "absent"; - if (typeof value === "number") return Number.isInteger(value) ? value : "invalid"; - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - if (/^\d+$/.test(expanded)) return Number(expanded); - } - return "invalid"; -} - -function resolveStringSlice( - value: unknown, - fallback: ReadonlyArray, - lookup: EnvLookup, -): ReadonlyArray | undefined { - if (value === undefined) return fallback; - if (typeof value === "string") { - const expanded = expandEnv(value, lookup); - return expanded.length === 0 ? [] : expanded.split(","); - } - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === "string")) { - return undefined; - } - return value.map((item) => expandEnv(item, lookup)); -} - -/** - * Joins `pattern` under `supabase/`, collapsing `.`/`..` segments (e.g. - * `../seed.sql` → `seed.sql`). The cleaned, forward-slash-only path is the - * seed-tracking hash key, so an uncollapsed key would miss a previously - * recorded entry and re-run the seed. - */ -function joinSupabaseSeedPath(pattern: string): string { - const out: Array = []; - for (const segment of `supabase/${pattern}`.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); - else out.push(".."); - } else { - out.push(segment); - } - } - return out.length === 0 ? "." : out.join("/"); -} - -/** - * A bare leading separator (`/schemas`) has no Windows volume, so it's - * relative and joins under `supabase/` — unlike Node's win32 `isAbsolute`, - * which treats it as rooted at the current drive. - */ -const isAbsolutePattern = (pathSvc: Path.Path, pattern: string): boolean => { - if (process.platform !== "win32") { - return pathSvc.isAbsolute(pattern); - } - const isSeparator = (c: string | undefined): boolean => c === "/" || c === "\\"; - // Drive-letter volume (`C:\`, `c:/`): `volumeNameLen` accepts any byte before - // `:` (case 2, `path[1] === ':'`), then `IsAbs` requires a separator right after. - if (pattern.length >= 3 && pattern[1] === ":" && isSeparator(pattern[2])) { - return true; - } - // UNC volume (`\\server\share`, `//server/share`): `IsAbs` treats a - // double-separator-prefixed volume as absolute unconditionally. - return pattern.length >= 2 && isSeparator(pattern[0]) && isSeparator(pattern[1]); -}; - -/** - * Resolves a single seed/schema-paths entry: a relative pattern is joined - * under `supabase/`; an absolute (or empty) pattern is returned verbatim. - * Used by the reader for `[db.seed].sql_paths` and - * `[db.migrations].schema_paths`, and by `db reset` for its `--sql-paths` - * override — all three feed the glob the same resolved paths. - */ -export const resolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => - pattern.length === 0 || isAbsolutePattern(pathSvc, pattern) - ? pattern - : joinSupabaseSeedPath(pattern); - -/** `[db]` ports default through the development env unless `SUPABASE_ENV` overrides. */ -const DEFAULT_SUPABASE_ENV = "development"; - -export const configEnvOption = Effect.fnUntraced(function* (name: string) { - return yield* Config.option(Config.String(name)).pipe( - Effect.mapError( - () => new DbConfigLoadError({ message: `failed to resolve environment variable: ${name}` }), - ), - ); -}); - -export const resolveShellEnv = Effect.fnUntraced(function* (names: Iterable) { - const resolved = new Map(); - for (const name of new Set(names)) { - const value = yield* configEnvOption(name); - if (Option.isSome(value)) resolved.set(name, value.value); - } - return resolved; -}); - -/** - * Loads the project's nested `.env` files into a sparse lookup map without mutating - * `process.env` (first writer wins; the ambient environment always wins over any file). - */ -export const loadProjectEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, -) { - const configuredEnv = yield* configEnvOption("SUPABASE_ENV"); - const env = Option.getOrElse( - configuredEnv.pipe(Option.filter((value) => value.length > 0)), - () => DEFAULT_SUPABASE_ENV, +export const loadProjectEnvValues = (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => + loadCliProjectEnvFiles(workdir).pipe( + Effect.map((loaded) => ({ ...loaded.values })), + Effect.mapError((cause) => new DbConfigLoadError({ message: cause.message })), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), ); - const filenames = [`.env.${env}.local`]; - if (env !== "test") filenames.push(".env.local"); - filenames.push(`.env.${env}`, ".env"); - // `supabase/` is searched before the repo root; first writer wins. - const dirs = [path.join(workdir, "supabase"), workdir]; - const loaded: Record = {}; - for (const dir of dirs) { - for (const name of filenames) { - // A missing file is skipped; any other read error aborts rather than silently - // running with a broken env file. - const content = yield* fs.readFileString(path.join(dir, name)).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.succeed(Option.none())), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read environment file: ${name}`, - }), - ), - ), - ), - ), - ); - if (Option.isNone(content)) continue; - const parsed = yield* Effect.try({ - try: () => parseDotEnv(content.value), - catch: () => - new DbConfigLoadError({ message: `failed to parse environment file: ${name}` }), - }); - for (const [key, value] of Object.entries(parsed)) { - // The shell env and earlier files win; never overrides an already-set key. - if (loaded[key] !== undefined) continue; - const ambientValue = yield* configEnvOption(key); - if (Option.isNone(ambientValue)) loaded[key] = value; - } - } - } - return loaded; -}); function nonEmptyString(value: unknown): Option.Option { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); } -/** - * Resolve a `[section] enabled` style bool: a native TOML bool, or a string - * (including an `env(VAR)` reference) accepted by {@link parseBoolLiteral}. - * Returns `"invalid"` for a malformed string; applies `fallback` when the key - * is absent. - */ -function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): boolean | "invalid" { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const parsed = parseBoolLiteral(expandEnv(value, lookup)); - return parsed ?? "invalid"; - } - // A numeric value decodes as a bool (`value != 0`), so `enabled = 0` is an explicit - // false, not absent. - if (typeof value === "number") return value !== 0; - // A present non-scalar (e.g. `enabled = []`) is a decode error, not absent — it must - // not fall through to the default and silently pass a config that should have failed. - if (value === undefined) return fallback; - return "invalid"; -} - -/** - * `resolveBool` that fails the config load on a malformed bool. `envValue` is - * the `SUPABASE_*` env override, which wins over the TOML value/default when - * set; an `env(VAR)` indirection in the override is expanded before parsing. - */ -const resolveBoolOrFail = Effect.fnUntraced(function* ( - field: string, - value: unknown, - fallback: boolean, - lookup: EnvLookup, - envValue?: string, -) { - if (envValue !== undefined) { - const parsed = parseBoolLiteral(expandEnv(envValue, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return parsed; - } - const resolved = resolveBool(value, fallback, lookup); - if (resolved === "invalid") { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return resolved; -}); - -/** - * Tri-state sibling of `resolveBoolOrFail` for fields that stay `None` (never - * `false`) when absent. The `SUPABASE_*` env override wins when present; - * otherwise a present TOML bool/string is decoded with {@link parseBoolLiteral}, - * and a malformed value aborts the load. - */ -const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( - field: string, - envValue: string | undefined, - value: unknown, - lookup: EnvLookup, -) { - if (envValue !== undefined) { - const parsed = parseBoolLiteral(expandEnv(envValue, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return Option.some(parsed); - } - if (typeof value === "boolean") return Option.some(value); - // A numeric value decodes the same way: `value != 0`. - if (typeof value === "number") return Option.some(value !== 0); - if (typeof value === "string") { - const parsed = parseBoolLiteral(expandEnv(value, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); - } - return Option.some(parsed); - } - // A present non-scalar value is a decode failure, not absent — reject it here - // rather than silently treating it as `None`. - if (value === undefined) return Option.none(); - return yield* new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }); -}); - const VAULT_SECRET_PATH = ["db", "vault", "*"] as const; /** @@ -1033,522 +318,162 @@ export const assertDecryptableSecrets = ( return undefined; }; -// An absent `auth.site_url` defaults to this value; only an explicit empty string fails. -const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.length === 0 ? undefined : value; + +const causeMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const parseErrorMessage = (cause: unknown): string => { + const detail = causeMessage(cause); + const head = detail.split("\n at ")[0] ?? detail; + return /^(Missing required|Invalid (?!TOML|JSON))/.test(head) + ? head + : `failed to load config: ${detail}`; +}; + +type ResolvedConfigLoadError = Effect.Error>; -const ENV_OVERRIDE_NAMES = [ - "SUPABASE_PROJECT_ID", - "SUPABASE_DB_PORT", - "SUPABASE_DB_SHADOW_PORT", - "SUPABASE_DB_MAJOR_VERSION", - "SUPABASE_DB_ORIOLEDB_VERSION", - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", - "SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH", - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - "SUPABASE_AUTH_ENABLED", - "SUPABASE_ANALYTICS_BACKEND", - "SUPABASE_ANALYTICS_ENABLED", - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - "SUPABASE_DB_MIGRATIONS_ENABLED", - "SUPABASE_DB_SEED_ENABLED", - "SUPABASE_DB_SEED_SQL_PATHS", - "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", - "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", - "SUPABASE_API_SCHEMAS", - "SUPABASE_EXPERIMENTAL_PG_DELTA", -] as const; +const toDbConfigLoadError = (error: ResolvedConfigLoadError) => { + if (isPassthroughLoadFailure(error)) return error; + switch (error._tag) { + case "CliConfigParseError": + return new DbConfigLoadError({ message: parseErrorMessage(error.cause) }); + case "PlatformError": + return new DbConfigLoadError({ message: `failed to read file config: ${error.message}` }); + default: + return new DbConfigLoadError({ message: error.message }); + } +}; -type EnvOverrideName = (typeof ENV_OVERRIDE_NAMES)[number]; +const loadDbTomlResolvedConfig = ( + workdir: string, + ref: string | undefined, + ignoreConfigFile: boolean, +) => + CliConfigValues.use((values) => + values.load({ + workdir, + projectRef: Option.fromNullishOr(ref), + ...(ignoreConfigFile ? { ignoreConfigFile: true as const } : {}), + }), + ).pipe(Effect.mapError(toDbConfigLoadError)); /** - * Reads `/supabase/config.toml` (db subtree + project id) and the linked - * `/supabase/.temp/pooler-url`. `fs`/`path` are passed in so the resolver - * can capture them once and keep its own `R` at `never`. + * Projects the `CliConfigValues` resolved config of `/supabase/config.{toml,json}` (flags aside) + * and the linked `/supabase/.temp/pooler-url` onto {@link DbTomlValues}. `fs`/`path` are + * passed in so the resolver can capture them once and keep its own `R` at `never`. * - * Fails with `DbConfigLoadError` only when the config file is present but - * unparseable; an absent file (and an absent/empty pooler-url file) is not an error. + * Fails with `DbConfigLoadError` when the config is present but unreadable, undecodable or invalid; + * an absent file (and an absent/empty pooler-url file) is not an error. */ const readDbTomlCore = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - // When set, the `[remotes.]` block whose `project_id` equals `ref` is merged - // over the base config before fields are read; omitted for `--local`/`--db-url`/declarative. + // The `[remotes.]` block whose `project_id` equals `ref` is the config-tier overlay; + // omitted for `--local`/`--db-url`/declarative. ref?: string, - // Internal: when true, `config.toml` is treated as absent so the body resolves pure - // defaults (env overrides still apply). Used as the fallback after a config-load - // failure by the lenient `readDbToml({ validate: false })` wrapper. + // The config file is treated as absent: the fallback after a load failure for `validate: false`. ignoreConfigFile = false, - // Internal: gates the OrioleDB S3 `assertEnvLoaded` WARN below so it prints at most - // once per command invocation. Callers that re-read the config internally after an - // earlier same-invocation read pass `false`. + // Gates the OrioleDB S3 unresolved-env WARN so it prints once per command invocation. warnOnUnresolvedEnv = true, resolveVaultSecrets = true, ) { const supabaseDir = path.join(workdir, "supabase"); - const configPath = path.join(supabaseDir, "config.toml"); - - // A missing file yields defaults; any other read error aborts rather than silently - // running against the default local database. - const maybeContent = ignoreConfigFile - ? Option.none() - : yield* fs.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.succeed(Option.none())), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), - ), - ), - ), - ); - - // Built before the remote-config validation/merge below, so remote and top-level - // `project_id` env() forms are expanded before they are validated or used to derive - // Docker IDs. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - let doc: RawDoc | undefined; - if (Option.isSome(maybeContent)) { - doc = yield* Effect.try({ - try: () => asRecord(SmolToml.parse(maybeContent.value)), - catch: (cause) => - new DbConfigLoadError({ - message: `failed to load config: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - // Same per-section promotion as the config loader, before the remote merge; the loader owns - // the deprecation warning. - doc = asRecord(normalizeDeprecatedOrioleDBVersion(doc).document); - } - const shellOverrideEnv = yield* resolveShellEnv(ENV_OVERRIDE_NAMES); - // `SUPABASE_DB_*` env vars override the matching `[db]` field before the TOML - // value/default. An empty env value is ignored, and the project `.env` files are - // loaded into the environment first, so consult both. - const envOverride = (name: EnvOverrideName): string | undefined => { - const fromShell = shellOverrideEnv.get(name); - if (fromShell !== undefined && fromShell.length > 0) return fromShell; - const fromFile = projectEnv[name]; - return fromFile !== undefined && fromFile.length > 0 ? fromFile : undefined; - }; - const shellRefEnv = yield* resolveShellEnv([ - ...envRefNames(doc), - ...Object.keys(asRecord(doc?.["remotes"]) ?? {}).map(remoteProjectIdEnvName), - ...ENV_OVERRIDE_NAMES.flatMap((name) => envRefNames(envOverride(name))), - ]); - const lookup: EnvLookup = (name) => shellRefEnv.get(name) ?? projectEnv[name]; - // dotenvx private keys for decrypting `encrypted:` secrets below. - const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnv, ...process.env }); - - let db: RawDoc | undefined; - let pgDeltaRaw: RawDoc | undefined; - let authRaw: RawDoc | undefined; - let storageRaw: RawDoc | undefined; - let realtimeRaw: RawDoc | undefined; - let apiRaw: RawDoc | undefined; - let edgeRuntimeRaw: RawDoc | undefined; - let experimentalRaw: RawDoc | undefined; - let functionsRaw: RawDoc | undefined; - let analyticsRaw: RawDoc | undefined; - let projectId = Option.none(); - // A present `project_id` that env-expands to empty; the `SUPABASE_PROJECT_ID` - // override below may still rescue it before the check further down fails the load. - let projectIdExplicitEmpty = false; - // Keys a matched remote block set at override tier; they must beat matching env - // overrides below. - let remoteOverrideKeys: ReadonlySet = new Set(); - // The matched `[remotes.]` block name, echoed as the config-override line. - let appliedRemote: string | undefined; - if (Option.isSome(maybeContent)) { - // Config load aborts when two `[remotes.*]` blocks share a `project_id`, - // regardless of which command runs — check before merging. - const duplicateRemote = findDuplicateRemoteProjectId(doc, lookup); - if (duplicateRemote !== undefined) { - return yield* new DbConfigLoadError({ - message: `duplicate project_id for [remotes.${duplicateRemote.name}] and [remotes.${duplicateRemote.other}]`, - }); - } - // Validation rejects any remote whose `project_id` is not a valid 20-char ref, on - // every load, after the duplicate check. So a malformed remote fails even - // local/direct commands before any DB connection. - const invalidRemote = findInvalidRemoteProjectId(doc, lookup); - if (invalidRemote !== undefined) { - return yield* new DbConfigLoadError({ - message: `Invalid config for remotes.${invalidRemote}.project_id. Must be like: abcdefghijklmnopqrst`, - }); - } - // Apply a matching `[remotes.]` override: merge the block whose - // `project_id` equals the resolved ref over the base. - const remoteOverride = - ref === undefined - ? { doc, remoteOverrideKeys: new Set() } - : applyRemoteOverride(doc, ref, lookup); - const effectiveDoc = remoteOverride.doc; - remoteOverrideKeys = remoteOverride.remoteOverrideKeys; - appliedRemote = remoteOverride.appliedRemote; - db = asRecord(effectiveDoc?.["db"]); - experimentalRaw = asRecord(effectiveDoc?.["experimental"]); - pgDeltaRaw = asRecord(experimentalRaw?.["pgdelta"]); - authRaw = asRecord(effectiveDoc?.["auth"]); - storageRaw = asRecord(effectiveDoc?.["storage"]); - realtimeRaw = asRecord(effectiveDoc?.["realtime"]); - apiRaw = asRecord(effectiveDoc?.["api"]); - edgeRuntimeRaw = asRecord(effectiveDoc?.["edge_runtime"]); - functionsRaw = asRecord(effectiveDoc?.["functions"]); - analyticsRaw = asRecord(effectiveDoc?.["analytics"]); - // Expand `env(VAR)` before deriving the Docker container name from `project_id` — - // otherwise `project_id = "env(PROJECT_ID)"` would sanitize to the literal string. - const rawProjectId = effectiveDoc?.["project_id"]; - projectId = nonEmptyString( - typeof rawProjectId === "string" ? expandEnv(rawProjectId, lookup) : rawProjectId, - ); - // A present `project_id` string that resolves to empty is a "kept empty override". - projectIdExplicitEmpty = typeof rawProjectId === "string" && Option.isNone(projectId); + const resolvedConfig = yield* loadDbTomlResolvedConfig(workdir, ref, ignoreConfigFile); + const projectEnv = resolvedConfig.projectEnvValues; + const { config } = resolvedConfig.materialized; + const fail = (message: string) => Effect.fail(new DbConfigLoadError({ message })); + const getKey = (key: CliConfigKey) => + resolvedConfig.get(key).pipe(Effect.mapError(toDbConfigLoadError)); - // Every secret-typed field must be decryptable before validation/connecting. Skips - // `[db.vault]` when the caller opts out, so the vault loop below only materializes - // already-proven-decryptable values. - const secretError = assertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys, { + const declaredDocument = resolvedConfig.loaded.document ?? {}; + const secretDocument = Object.fromEntries( + ["db", "auth", "studio", "edge_runtime", "remotes"].map((key) => [key, declaredDocument[key]]), + ); + const referenced = yield* resolvedConfig + .envValues(envReferenceNames(secretDocument)) + .pipe(Effect.mapError(toDbConfigLoadError)); + const lookup: EnvLookup = (name) => referenced[name]; + const secretError = assertDecryptableSecrets( + secretDocument, + lookup, + resolvedConfig.dotenvPrivateKeys, + { includeVault: resolveVaultSecrets, - }); - if (secretError !== undefined) { - return yield* new DbConfigLoadError({ message: secretError }); - } - } - // `remoteOverrideKeys` has its final value from here on — see `makeRemoteWins`'s own doc - // comment for why this is typed narrower than the `ReadonlySet` it wraps. - const remoteWins = makeRemoteWins(remoteOverrideKeys); + }, + ); + if (secretError !== undefined) return yield* fail(secretError); - // Read the linked pooler URL from `.temp/pooler-url` and treat it as configured only - // when the file exists and is non-empty. const poolerUrlPath = path.join(supabaseDir, ".temp", "pooler-url"); const poolerConnectionString = yield* fs .readFileString(poolerUrlPath) .pipe(Effect.map(nonEmptyString), Effect.orElseSucceed(Option.none)); - // `SUPABASE_PROJECT_ID` overrides the top-level `project_id` used to name the local - // stack's Docker resources, unless a matched `[remotes.]` block already set - // `project_id` at override tier — that value must win over a stale/differently-scoped - // env var. - const projectIdEnv = remoteWins("project_id") ? undefined : envOverride("SUPABASE_PROJECT_ID"); - if (projectIdEnv !== undefined) { - projectId = nonEmptyString(expandEnv(projectIdEnv, lookup)); + const configuredProjectId = yield* getKey(CliConfigKeys.projectId); + if ( + configuredProjectId.origin.tier !== "default" && + (configuredProjectId.unnormalized ?? configuredProjectId.value) === "" + ) { + return yield* fail("Missing required field in config: project_id"); } + const projectId = configuredProjectId.value; - // An absent `project_id` is tolerated here (deferred); a present `project_id = ""` - // that the env override didn't rescue fails the load, so a destructive command (e.g. - // remote `db reset`) fails fast instead of running against a config that should have - // already failed validation. - if (projectIdExplicitEmpty && Option.isNone(projectId)) { - return yield* new DbConfigLoadError({ - message: "Missing required field in config: project_id", - }); - } - - // A present-but-invalid port aborts rather than defaulting, so a broken `[db]` - // config never silently targets the default local database. - const port = resolvePort( - (remoteWins("db.port") ? undefined : envOverride("SUPABASE_DB_PORT")) ?? db?.["port"], - DEFAULT_PORT, - lookup, - ); - const shadowPort = resolvePort( - (remoteWins("db.shadow_port") ? undefined : envOverride("SUPABASE_DB_SHADOW_PORT")) ?? - db?.["shadow_port"], - DEFAULT_SHADOW_PORT, - lookup, - ); - if (port === undefined || shadowPort === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to load config: invalid ${port === undefined ? "db.port" : "db.shadow_port"} value`, - }); - } - // An explicit `db.port = 0` is a load error (an absent port is defaulted first); - // `resolvePort` accepts 0 as a valid port, so the zero check lives here. No - // equivalent check for `shadow_port`. - if (port === 0) { - return yield* new DbConfigLoadError({ message: "Missing required field in config: db.port" }); - } - - // `db.password` isn't part of the config schema — it's a TS-only extension for - // `--local` connections. Must not read `DB_PASSWORD` (linked-only), or `db query - // --local` etc. would authenticate with a remote secret. - const passwordRaw = typeof db?.["password"] === "string" ? db["password"] : undefined; + const { port, shadow_port: shadowPort, major_version: majorVersion } = config.db; + if (port === 0) return yield* fail("Missing required field in config: db.port"); - // `env(VAR)` is expanded, then the result must decode as a whole integer (`17foo` is - // not truncated to 17); `SUPABASE_DB_MAJOR_VERSION` overrides the TOML value. - const majorVersionRaw = - (remoteWins("db.major_version") ? undefined : envOverride("SUPABASE_DB_MAJOR_VERSION")) ?? - db?.["major_version"]; - const majorVersionResolved = resolveConfigInt(majorVersionRaw, lookup); - if (majorVersionResolved === "invalid") { - // Present but not a whole integer (`17foo`, or an `env(VAR)` that does not - // resolve to digits): fail the config parse rather than defaulting. - const shown = - typeof majorVersionRaw === "string" - ? expandEnv(majorVersionRaw, lookup) - : String(majorVersionRaw); - return yield* new DbConfigLoadError({ - message: `Failed reading config: Invalid db.major_version: ${shown}.`, - }); - } - // An unsupported major version is rejected by the single `validateResolvedConfig` - // call below; an absent value defaults first, a present one (including 0) flows through. - const majorVersion = - typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; - - // On a 15/17 project, validation rewrites the Postgres image to the OrioleDB tag and - // checks the four S3 fields below; the image rewrite itself happens in `resolveDbImage`. - const expandString = (value: unknown): Option.Option => - typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); - const orioledbVersionRaw = - (remoteWins("db.orioledb_version") ? undefined : envOverride("SUPABASE_DB_ORIOLEDB_VERSION")) ?? - db?.["orioledb_version"]; - const orioledbVersion = expandString(orioledbVersionRaw); - // The defaults-only fallback says nothing about the project's engine. + const orioledbVersion = nonEmptyString(config.db.orioledb_version); if (!ignoreConfigFile) { yield* recordOrioleDbTelemetry(Option.getOrUndefined(orioledbVersion), majorVersion); } - if (selectsOrioleDb(Option.getOrUndefined(orioledbVersion), majorVersion)) { - // Warns (does not fail) when an S3 field still holds an unexpanded `env(VAR)`; - // matches the established stderr line, with the env var name from the capture. - const s3Fields = ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const; - for (const field of s3Fields) { - const raw = experimentalRaw?.[field]; - if (typeof raw !== "string") continue; - const expanded = expandEnv(raw, lookup); - const unset = ENV_PATTERN.exec(expanded); - if (unset !== null && warnOnUnresolvedEnv) { + if ( + selectsOrioleDb(Option.getOrUndefined(orioledbVersion), majorVersion) && + warnOnUnresolvedEnv + ) { + for (const field of ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const) { + const unset = ENV_PATTERN.exec(config.experimental[field] ?? ""); + if (unset !== null) { process.stderr.write(`WARN: environment variable is unset: ${unset[1] ?? ""}\n`); } } } - // Selects the edge-runtime image tag (`deno1` when 1, otherwise the default); pg-delta - // needs it to pick the matching image. `SUPABASE_EDGE_RUNTIME_DENO_VERSION` overrides - // the TOML value before validation. - const denoVersionRaw = - (remoteWins("edge_runtime.deno_version") - ? undefined - : envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION")) ?? edgeRuntimeRaw?.["deno_version"]; - // A present non-integer string or unresolved `env(MISSING)` aborts the load rather - // than falling through to the default Deno 2 image. - const denoVersionResolved = resolveConfigInt(denoVersionRaw, lookup); - if (denoVersionResolved === "invalid") { - const shown = - typeof denoVersionRaw === "string" - ? expandEnv(denoVersionRaw, lookup) - : String(denoVersionRaw); - return yield* new DbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${shown}.`, - }); - } - // An invalid deno_version is rejected by the single `validateResolvedConfig` call - // below; an absent key falls through to the default (2). - const denoVersion = - typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; - - // `[experimental.webhooks]` only exists to be turned ON: any present section whose - // `enabled` isn't explicitly `true` is rejected. Unlike `pgdelta.enabled` below, - // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED` only applies when the section itself is - // declared — gate the env read on `webhooksPresent`, not just later validation. - const webhooksRaw = asRecord(experimentalRaw?.["webhooks"]); - const webhooksPresent = webhooksRaw !== undefined; - const webhooksEnabledRaw = webhooksRaw?.["enabled"]; - const webhooksEnabledEnv = webhooksPresent - ? remoteWins("experimental.webhooks.enabled") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED") - : undefined; - let webhooksEnabled: boolean; - if (webhooksEnabledEnv !== undefined) { - const expandedWebhooksEnabledEnv = expandEnv(webhooksEnabledEnv, lookup); - const parsed = parseBoolLiteral(expandedWebhooksEnabledEnv); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandedWebhooksEnabledEnv}.`, - }); - } - webhooksEnabled = parsed; - } else if (typeof webhooksEnabledRaw === "boolean") { - webhooksEnabled = webhooksEnabledRaw; - } else if (typeof webhooksEnabledRaw === "number") { - // A numeric `enabled = 1` is true (`value != 0`), same as `experimental.pgdelta.enabled` below. - webhooksEnabled = webhooksEnabledRaw !== 0; - } else if (typeof webhooksEnabledRaw === "string") { - const parsed = parseBoolLiteral(expandEnv(webhooksEnabledRaw, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandEnv(webhooksEnabledRaw, lookup)}.`, - }); - } - webhooksEnabled = parsed; - } else { - webhooksEnabled = false; - } - - // `[experimental.pgdelta]`. `enabled` accepts a TOML bool or a string (including - // `env(VAR)`); `declarative_schema_path` is resolved to a `supabase/`-prefixed path - // when relative. `SUPABASE_EXPERIMENTAL_PGDELTA_*` overrides the TOML before - // validation. - const enabledRaw = pgDeltaRaw?.["enabled"]; - const enabledEnv = remoteWins("experimental.pgdelta.enabled") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"); - // `"1"` counts as true and a malformed value aborts the load. The env override wins, - // then the TOML bool, then an `env(VAR)` string, defaulting to true when absent. - let enabled: boolean; - if (enabledEnv !== undefined) { - // An `env(VAR)` indirection in the override is expanded before the bool parse. - const expandedEnabledEnv = expandEnv(enabledEnv, lookup); - const parsed = parseBoolLiteral(expandedEnabledEnv); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandedEnabledEnv}.`, - }); - } - enabled = parsed; - } else if (typeof enabledRaw === "boolean") { - enabled = enabledRaw; - } else if (typeof enabledRaw === "number") { - // A numeric `enabled = 1` is true (`value != 0`), same rule as the generic `resolveBool`. - enabled = enabledRaw !== 0; - } else if (typeof enabledRaw === "string") { - const parsed = parseBoolLiteral(expandEnv(enabledRaw, lookup)); - if (parsed === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandEnv(enabledRaw, lookup)}.`, - }); - } - enabled = parsed; - } else { - enabled = true; - } + const denoVersion = config.edge_runtime.deno_version; - const declarativeSchemaPathRaw = pgDeltaRaw?.["declarative_schema_path"]; - // Expand `env(VAR)` in whichever source wins (override or TOML literal) before the - // path is used (`expandEnv` is a no-op on a non-`env()` string). - const declarativeSchemaPathValue = expandEnv( - (remoteWins("experimental.pgdelta.declarative_schema_path") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH")) ?? - (typeof declarativeSchemaPathRaw === "string" ? declarativeSchemaPathRaw : ""), - lookup, - ); - let declarativeSchemaPath = Option.none(); - if (declarativeSchemaPathValue.length > 0) { - declarativeSchemaPath = Option.some( - path.isAbsolute(declarativeSchemaPathValue) - ? declarativeSchemaPathValue - : path.join("supabase", declarativeSchemaPathValue), - ); - } - - const formatOptionsRaw = pgDeltaRaw?.["format_options"]; - // Expand the resolved value (env override or TOML literal) before the JSON validation - // below runs. - const formatOptionsExpanded = expandEnv( - (remoteWins("experimental.pgdelta.format_options") - ? undefined - : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS")) ?? - (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), - lookup, - ); - // A non-empty, non-JSON `format_options` is rejected by the single - // `validateResolvedConfig` call below. - const formatOptions = nonEmptyString(formatOptionsExpanded); + const webhooksPresent = resolvedConfig.declares("experimental.webhooks"); + const webhooksEnabled = config.experimental.webhooks?.enabled ?? false; + const pgDeltaConfig = config.experimental.pgdelta; + const declarativeSchemaPath = nonEmptyString(pgDeltaConfig?.declarative_schema_path); + const formatOptions = nonEmptyString(pgDeltaConfig?.format_options); - // Bucket-name/function-slug validation lives in `validateResolvedConfig` (called - // once, below); only the pure extraction stays here. - const bucketsRaw = asRecord(storageRaw?.["buckets"]); - // Validates each bucket's own `file_size_limit` eagerly, so a malformed value is - // caught even on a restart that never reaches Storage bring-up. Validate-only — - // `seedBucketsRun` re-parses the real value when it actually seeds. - if (bucketsRaw !== undefined) { - for (const [bucketName, bucketRaw] of Object.entries(bucketsRaw)) { - const rawLimit = asRecord(bucketRaw)?.["file_size_limit"]; - if (typeof rawLimit !== "string" && typeof rawLimit !== "number") continue; - const limitString = - typeof rawLimit === "number" ? String(rawLimit) : expandEnv(rawLimit, lookup); - yield* Effect.try({ - try: () => ramInBytes(limitString), - catch: () => - new DbConfigLoadError({ - message: `failed to parse config: invalid storage.buckets.${bucketName}.file_size_limit.`, - }), - }); - } + const buckets = config.storage.buckets ?? {}; + for (const [bucketName, bucket] of Object.entries(buckets)) { + yield* Effect.try({ + try: () => ramInBytes(bucket.file_size_limit), + catch: () => + new DbConfigLoadError({ + message: `failed to parse config: invalid storage.buckets.${bucketName}.file_size_limit.`, + }), + }); } - // Gated on `auth.enabled` (default true); `SUPABASE_AUTH_ENABLED` decides whether the - // auth block is validated, unless a matched remote block set `auth.enabled` itself. - const authEnabled = yield* resolveBoolOrFail( - "auth.enabled", - authRaw?.["enabled"], - true, - lookup, - remoteWins("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), - ); - - // `str`/`gate`/`fail` build `AuthInput` for the shared validator call below, and back - // the sms/external checks further down that only this reader performs. - const fail = (message: string) => Effect.fail(new DbConfigLoadError({ message })); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved - // `env(VAR)` stays literal (non-empty). - const str = (rec: RawDoc | undefined, key: string): string => { - const value = rec?.[key]; - return typeof value === "string" ? expandEnv(value, lookup) : ""; - }; - // Accepts a boolean, nonzero number, or parseable string; a malformed string aborts - // the load rather than coercing to false. Absent/non-string → false. - 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 = parseBoolLiteral(expandEnv(value, lookup)); - if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); - return parsed; - }); + const authEnabled = config.auth.enabled; + const { auth } = config; - const authRawResolved = authRaw ?? {}; let authInput: AuthInput | undefined; if (authEnabled) { - // A1: site_url required. - const siteUrl = - authRawResolved["site_url"] === undefined - ? DEFAULT_AUTH_SITE_URL - : str(authRawResolved, "site_url"); - - // A4: [auth.captcha]. The provider enum check and the `enabled`-gated - // required-field checks both live in `validateResolvedConfig`. - const captchaRaw = asRecord(authRawResolved["captcha"]); - let captchaInput: CaptchaInput | 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 (I/O). A relative path resolves under the supabase - // dir; absolute is verbatim. - const signingKeysPath = str(authRawResolved, "signing_keys_path"); + const captcha = auth.captcha; + const captchaInput: CaptchaInput | undefined = + captcha === undefined + ? undefined + : { + enabled: captcha.enabled, + provider: nonEmpty(captcha.provider), + secret: nonEmpty(captcha.secret), + }; + + const signingKeysPath = auth.signing_keys_path ?? ""; if (signingKeysPath.length > 0) { const keysJson = yield* fs .readFileString(resolveSigningKeysPath(workdir, signingKeysPath)) @@ -1572,197 +497,129 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } - // A6: passkey/webauthn when passkey enabled. - const passkeyRaw = asRecord(authRawResolved["passkey"]); let passkeyInput: PasskeyInput | undefined; - if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { - const webauthnRaw = asRecord(authRawResolved["webauthn"]); - const rpOriginsRaw = webauthnRaw?.["rp_origins"]; - // `rp_origins` may be a comma-separated string (raw or `env(...)`-resolved) rather - // than a literal TOML array; split it, matching `local-config-values.ts`'s - // handling of this same field. - const rpOrigins = Array.isArray(rpOriginsRaw) - ? rpOriginsRaw - : strToArr(str(webauthnRaw, "rp_origins")); + if ((yield* getKey(CliConfigKeys.auth.passkey.enabled)).value) { + const rpOrigins = (yield* getKey(CliConfigKeys.auth.webauthn.rpOrigins)).value; passkeyInput = { - webauthnPresent: webauthnRaw !== undefined, - rpId: str(webauthnRaw, "rp_id"), + webauthnPresent: resolvedConfig.declares("auth.webauthn"), + rpId: (yield* getKey(CliConfigKeys.auth.webauthn.rpId)).value, rpOrigins: rpOrigins.length > 0 ? rpOrigins : undefined, }; } - // B1: hooks — each enabled hook, checked in this fixed order. - const hookRaw = asRecord(authRawResolved["hook"]); - const hookTypes = [ + const hooks: Array = []; + for (const type of [ "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") }); - } + ] as const) { + const hook = auth.hook[type]; + if (hook.enabled) hooks.push({ type, uri: hook.uri ?? "", secrets: hook.secrets ?? "" }); } - // B2: mfa — enroll requires verify, 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`), - }); - } + const mfa: Array = (["totp", "phone", "web_authn"] as const).map((label) => ({ + label, + enrollEnabled: auth.mfa[label].enroll_enabled, + verifyEnabled: auth.mfa[label].verify_enabled, + })); - // B3: email — template/notification content is I/O. Config loading resolves every - // relative `content_path` from the project root; absolute paths remain unchanged. - 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: () => - resolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => + const emailContentPath = Effect.fnUntraced(function* ( + section: "template" | "notification", + name: string, + entry: { readonly content_path?: string }, + ) { + const family = cliConfigRegistry.families.find( + (candidate) => + candidate.id === (section === "template" ? "authEmailTemplate" : "authEmailNotification"), + ); + const contentKey = + family === undefined ? undefined : cliConfigFamilyKey(family, name, "content"); + const contentPresent = + contentKey !== undefined && (yield* getKey(contentKey)).origin.tier !== "default"; + return yield* Effect.try({ + try: () => + resolveEmailTemplateContentPath({ + section, + name, + contentPath: entry.content_path ?? "", + contentPresent, + base: workdir, + }), + catch: (cause) => new DbConfigLoadError({ message: causeMessage(cause) }), + }); + }); + for (const [name, template] of Object.entries(auth.email.template)) { + const contentPath = yield* emailContentPath("template", name, template); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), + message: emailContentPathReadErrorMessage("template", name, cause), }), - }); - if (contentPath === undefined) continue; - yield* fs.readFileString(contentPath).pipe( - Effect.mapError( - (cause) => - new DbConfigLoadError({ - message: emailContentPathReadErrorMessage("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: () => - resolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => + for (const [name, notification] of Object.entries(auth.email.notification)) { + if (!notification.enabled) continue; + const contentPath = yield* emailContentPath("notification", name, notification); + if (contentPath === undefined) continue; + yield* fs.readFileString(contentPath).pipe( + Effect.mapError( + (cause) => new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), + message: emailContentPathReadErrorMessage("notification", name, cause), }), - }); - if (contentPath === undefined) continue; - yield* fs.readFileString(contentPath).pipe( - Effect.mapError( - (cause) => - new DbConfigLoadError({ - message: emailContentPathReadErrorMessage("notification", name, cause), - }), - ), - ); - } - } - // A present `[auth.email.smtp]` table defaults `enabled` to true unless explicitly - // disabled. - const smtpRaw = asRecord(emailRaw?.["smtp"]); - let smtpInput: SmtpInput | undefined; - if (smtpRaw !== undefined) { - const smtpPortRaw = smtpRaw["port"]; - // The shared validator's required-field check is `port === 0`. A non-numeric `port` - // (or unresolved `env(VAR)`) parses to `NaN` here — normalize that to `0` so it - // still trips the check instead of silently passing. - const smtpPortNumeric = - typeof smtpPortRaw === "number" - ? smtpPortRaw - : typeof smtpPortRaw === "string" - ? Number(expandEnv(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, checked in this fixed order. - // `aws_cognito`'s error messages say "cognito", not "aws_cognito". - const thirdPartyRaw = asRecord(authRawResolved["third_party"]); + const smtp = auth.email.smtp; + const smtpInput: SmtpInput | undefined = + smtp === undefined + ? undefined + : { + enabled: resolveSmtpEnabled(resolvedConfig), + host: smtp.host ?? "", + port: smtp.port ?? 0, + user: smtp.user ?? "", + pass: smtp.pass ?? "", + adminEmail: smtp.admin_email ?? "", + }; + + const thirdPartyConfig = auth.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") }); + if (thirdPartyConfig.firebase.enabled) { + thirdParty.push({ + provider: "firebase", + requiredField: thirdPartyConfig.firebase.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") }); + if (thirdPartyConfig.auth0.enabled) { + thirdParty.push({ provider: "auth0", requiredField: thirdPartyConfig.auth0.tenant ?? "" }); } - const cognitoRaw = asRecord(thirdPartyRaw?.["aws_cognito"]); - if ( - cognitoRaw !== undefined && - (yield* gate(cognitoRaw, "enabled", "auth.third_party.aws_cognito.enabled")) - ) { + if (thirdPartyConfig.aws_cognito.enabled) { thirdParty.push({ provider: "cognito", - requiredField: str(cognitoRaw, "user_pool_id"), - cognitoUserPoolRegion: str(cognitoRaw, "user_pool_region"), + requiredField: thirdPartyConfig.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: thirdPartyConfig.aws_cognito.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") }); + if (thirdPartyConfig.clerk.enabled) { + thirdParty.push({ provider: "clerk", requiredField: thirdPartyConfig.clerk.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") }); + if (thirdPartyConfig.workos.enabled) { + thirdParty.push({ + provider: "workos", + requiredField: thirdPartyConfig.workos.issuer_url ?? "", + }); } authInput = { - siteUrl, + siteUrl: auth.site_url, captcha: captchaInput, passkey: passkeyInput, hooks, @@ -1772,55 +629,23 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // Computed after the auth block, before the shared validator call: `analyticsEnabled` - // can itself fail on a malformed bool, and that failure must report after an - // auth-block error for a config broken in both places. An absent `[analytics]` - // section defaults to enabled+postgres. - const analyticsString = ( - key: "backend" | "gcp_project_id" | "gcp_project_number" | "gcp_jwt_path", - envName: EnvOverrideName, - ): string => { - const fromEnv = remoteWins(`analytics.${key}`) ? undefined : envOverride(envName); - const raw = fromEnv ?? analyticsRaw?.[key]; - return typeof raw === "string" ? expandEnv(raw, lookup) : ""; - }; - const analyticsBackend = analyticsString("backend", "SUPABASE_ANALYTICS_BACKEND"); - const analyticsEnabled = yield* resolveBoolOrFail( - "analytics.enabled", - analyticsRaw?.["enabled"], - true, - lookup, - remoteWins("analytics.enabled") ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), - ); - // Each GCP value is env-expanded, so an unresolved `env(VAR)` stays non-empty and - // passes the shared validator's `length === 0` check. - 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 check `validateResolvedConfig` owns runs through this single call (except - // `db.port`, checked earlier above). The sms/external checks below, which only this - // reader performs, run after this call succeeds, still gated on `authEnabled`. - const dbInput: DbInput = { port, majorVersion }; + const { analytics } = config; const analyticsInput: AnalyticsInput = { - enabled: analyticsEnabled, - backend: analyticsBackend.length > 0 ? analyticsBackend : undefined, - gcpProjectId, - gcpProjectNumber, - gcpJwtPath, + enabled: analytics.enabled, + backend: nonEmpty(analytics.backend), + gcpProjectId: analytics.gcp_project_id ?? "", + gcpProjectNumber: analytics.gcp_project_number ?? "", + gcpJwtPath: analytics.gcp_jwt_path ?? "", }; const experimentalInput: ExperimentalInput = { webhooksPresent, webhooksEnabled, - pgdeltaFormatOptions: formatOptionsExpanded, + pgdeltaFormatOptions: pgDeltaConfig?.format_options ?? "", }; const validationInput: ConfigValidationInput = { - db: dbInput, - storageBucketNames: bucketsRaw !== undefined ? Object.keys(bucketsRaw) : [], - functionSlugs: functionsRaw !== undefined ? Object.keys(functionsRaw) : [], + db: { port, majorVersion }, + storageBucketNames: Object.keys(buckets), + functionSlugs: Object.keys(config.functions ?? {}), auth: authInput, edgeRuntimeDenoVersion: denoVersion, analytics: analyticsInput, @@ -1828,303 +653,34 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; yield* Effect.try({ try: () => validateResolvedConfig(validationInput), - catch: (cause) => - new DbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), + catch: (cause) => new DbConfigLoadError({ message: causeMessage(cause) }), }); if (authEnabled) { - // B4: sms — only this reader validates it; only the first enabled provider is checked. - 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", - ); - 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 — only this reader validates it. linkedin/slack are - // deprecated and skipped. - 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`); - } - } - } - - // `[db.vault]` secret names, sorted (`setupInputsToken` sorts before hashing). - const vaultRaw = asRecord(db?.["vault"]); - const vaultNames = vaultRaw === undefined ? [] : Object.keys(vaultRaw).sort(); - - // `[db.migrations] enabled` — default true, overridable by - // `SUPABASE_DB_MIGRATIONS_ENABLED` unless the matched remote block explicitly set it. - const migrationsRaw = asRecord(db?.["migrations"]); - const migrationsEnabled = yield* resolveBoolOrFail( - "db.migrations.enabled", - migrationsRaw?.["enabled"], - true, - lookup, - remoteWins("db.migrations.enabled") ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), - ); - // `[db.seed]` — default enabled true, sql_paths `["seed.sql"]`; relative patterns are - // supabase-prefixed. Overridable by `SUPABASE_DB_SEED_ENABLED` unless a matched remote - // block supplied it at the override tier (set or forced false). - const seedRaw = asRecord(db?.["seed"]); - const seedEnabled = yield* resolveBoolOrFail( - "db.seed.enabled", - seedRaw?.["enabled"], - true, - lookup, - remoteWins("db.seed.enabled") ? undefined : envOverride("SUPABASE_DB_SEED_ENABLED"), - ); - // A string value (env override or TOML string) is env-expanded, then comma-split (no - // trimming; empty → `[]`). An array is decoded element-by-element: each element is - // expanded but not re-split, so `["env(SEEDS)"]` stays one pattern. The env override - // wins over the TOML value; absent/invalid falls back to the caller's default. - const splitSeedPaths = (value: string): ReadonlyArray => { - const expanded = expandEnv(value, lookup); - return expanded.length === 0 ? [] : expanded.split(","); - }; - /** - * Formats a float the way a weakly-decoded TOML `Glob` array element renders as a - * string: always fixed notation (never scientific), `+Inf`/`-Inf`/`NaN` spelled that - * way, and a negative zero keeps its sign — so the resolved pattern's hash key matches - * an existing recorded entry. - */ - const formatWeakFloat = (value: number): string => { - if (Number.isNaN(value)) return "NaN"; - if (value === Number.POSITIVE_INFINITY) return "+Inf"; - if (value === Number.NEGATIVE_INFINITY) return "-Inf"; - if (Object.is(value, -0)) return "-0"; - const str = value.toString(); - const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(str); - if (match === null) return str; - const [, sign = "", intPart = "", fracPart = "", expStr = "0"] = match; - const digits = intPart + fracPart; - const pointPos = intPart.length + Number(expStr); - if (pointPos <= 0) return `${sign}0.${"0".repeat(-pointPos)}${digits}`; - if (pointPos >= digits.length) return `${sign}${digits}${"0".repeat(pointPos - digits.length)}`; - return `${sign}${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`; - }; - // A non-string array element isn't dropped: a bool coerces to `"1"`/`"0"` and a - // number to its decimal string, then flows through the same env-expand/resolve - // pipeline as a real string entry. - const weakCoerceGlobEntry = (value: unknown): string | undefined => { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "1" : "0"; - if (typeof value === "number") return formatWeakFloat(value); - return undefined; - }; - // A non-scalar glob element (nested array/table, or a bare TOML datetime) fails with - // an "expected a string" error instead of being silently dropped; each datetime - // variant reports its own type name for the message. `smol-toml` parses every TOML - // datetime to a `TomlDate` (a `Date` subclass), exposing the `isDate`/`isTime`/ - // `isLocal` discriminators needed to pick the right variant name. - const tomlDateTypeName = (value: SmolToml.TomlDate): string => { - if (value.isDate()) return "local date"; - if (value.isTime()) return "local time"; - return value.isLocal() ? "local date-time" : "offset date-time"; - }; - const unsupportedTypeName = (value: unknown): string | undefined => - value instanceof SmolToml.TomlDate - ? tomlDateTypeName(value) - : Array.isArray(value) - ? "array" - : typeof value === "object" && value !== null - ? "table" - : undefined; - // Returns the "expected a string" issue for each bad array element, without - // failing — both `Glob` fields' issues are combined into one error afterward (see - // `failOnGlobIssues`), with `db.migrations.schema_paths` ordered before `db.seed.sql_paths`. - const globArrayIssues = ( - keyPath: string, - values: ReadonlyArray, - ): ReadonlyArray => - values.flatMap((value, index) => { - const typeName = unsupportedTypeName(value); - return typeName === undefined - ? [] - : [`${keyPath}[${index}]: expected a string, got ${typeName}`]; + yield* Effect.try({ + try: () => { + validateAuthSmsProviders(auth.sms); + validateAuthExternalProviders( + resolveAuthExternalProviders(asRecord(declaredDocument["auth"]), auth.external), + ); + }, + catch: (cause) => new DbConfigLoadError({ message: causeMessage(cause) }), }); - // Fails once with every issue collected across both `Glob` fields, instead of - // failing on the first field checked. - const failOnGlobIssues = ( - issues: ReadonlyArray, - ): Effect.Effect => - issues.length === 0 ? Effect.void : fail(`failed to parse config:\n${issues.join("\n")}`); - // A scalar top-level value (e.g. `schema_paths = 42`) is treated like a - // single-element array: a zero-length map decodes to `[]`, anything else weakly - // coerces or reports an unsupported-type issue. A `TomlDate` must not match the - // zero-length-map case — its value is stored internally, so `Object.keys` is empty - // too, but it should still be reported as an unsupported type. - const resolveScalarGlobFallback = ( - keyPath: string, - value: unknown, - ): { readonly resolved: ReadonlyArray; readonly issues: ReadonlyArray } => { - if ( - typeof value === "object" && - value !== null && - !(value instanceof SmolToml.TomlDate) && - Object.keys(value).length === 0 - ) { - return { resolved: [], issues: [] }; - } - const coerced = weakCoerceGlobEntry(value); - if (coerced !== undefined) { - return { resolved: [coerced], issues: [] }; - } - return { resolved: [], issues: globArrayIssues(keyPath, [value]) }; - }; - /** - * Resolves one `Glob`-typed field into its pre-supabase-join patterns: env override, - * real array, bare string, absent key (caller's default), or the scalar fallback - * above. Returns issues instead of failing — see {@link failOnGlobIssues}. - */ - const resolveGlobField = ( - keyPath: string, - raw: unknown, - override: string | undefined, - absentDefault: ReadonlyArray, - ): { readonly patterns: ReadonlyArray; readonly issues: ReadonlyArray } => { - if (override !== undefined) { - return { patterns: splitSeedPaths(override), issues: [] }; - } - if (Array.isArray(raw)) { - return { - patterns: raw - .map((pattern) => weakCoerceGlobEntry(pattern)) - .filter((pattern): pattern is string => pattern !== undefined) - .map((pattern) => expandEnv(pattern, lookup)), - issues: globArrayIssues(keyPath, raw), - }; - } - if (typeof raw === "string") { - return { patterns: splitSeedPaths(raw), issues: [] }; - } - if (raw === undefined) { - return { patterns: absentDefault, issues: [] }; - } - const fallback = resolveScalarGlobFallback(keyPath, raw); - return { - patterns: fallback.resolved.map((pattern) => expandEnv(pattern, lookup)), - issues: fallback.issues, - }; - }; - const rawSqlPaths = seedRaw?.["sql_paths"]; - const sqlPathsOverride = remoteWins("db.seed.sql_paths") - ? undefined - : envOverride("SUPABASE_DB_SEED_SQL_PATHS"); - const sqlPathsResolved = resolveGlobField("db.seed.sql_paths", rawSqlPaths, sqlPathsOverride, [ - "seed.sql", - ]); - // Patterns are already env-expanded above (expansion runs before the split); resolve - // each to its config-load form (absolute verbatim, relative supabase-joined). - const seedSqlPaths = sqlPathsResolved.patterns.map((pattern) => - resolveSeedSqlPath(path, pattern), - ); - - // `[db.migrations] schema_paths` — default `[]`, resolved the same way as - // `[db.seed].sql_paths` above, but unconditionally (not gated on `db.migrations.enabled`). - const rawSchemaPaths = migrationsRaw?.["schema_paths"]; - const schemaPathsOverride = remoteOverrideKeys.has("db.migrations.schema_paths") - ? undefined - : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"); - const schemaPathsResolved = resolveGlobField( - "db.migrations.schema_paths", - rawSchemaPaths, - schemaPathsOverride, - [], - ); - - // Combines both `Glob` fields' issues before failing once, so a config invalid in - // both surfaces both instead of only the first field checked. - yield* failOnGlobIssues([...schemaPathsResolved.issues, ...sqlPathsResolved.issues]); - - const schemaPaths = schemaPathsResolved.patterns.map((pattern) => - resolveSeedSqlPath(path, pattern), - ); + } - // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` - // ciphertext. `resolved` is true only after a successful decrypt-or-passthrough. An - // `encrypted:` value that cannot be decrypted aborts the command, never silently skipped. + const vaultConfig = config.db.vault ?? {}; + const vaultNames = Object.keys(vaultConfig).sort(); const vault: Array = []; - if (resolveVaultSecrets && vaultRaw !== undefined) { - for (const name of Object.keys(vaultRaw).sort()) { - const raw = vaultRaw[name]; - const value = typeof raw === "string" ? expandEnv(raw, lookup) : ""; - // Empty or an unexpanded `env(...)` reference is left unresolved rather than hashed. + if (resolveVaultSecrets) { + for (const name of vaultNames) { + const value = vaultConfig[name] ?? ""; if (value.length === 0 || ENV_PATTERN.test(value)) { vault.push({ name, value, resolved: false }); continue; } if (isEncryptedSecret(value)) { - const decrypted = decryptSecret(value, dotenvPrivateKeys); - if (!decrypted.ok) { - return yield* new DbConfigLoadError({ - message: `failed to parse config: ${decrypted.error}`, - }); - } + const decrypted = decryptSecret(value, resolvedConfig.dotenvPrivateKeys); + if (!decrypted.ok) return yield* fail(`failed to parse config: ${decrypted.error}`); vault.push({ name, value: decrypted.value, resolved: true }); continue; } @@ -2132,70 +688,40 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // `[api] auto_expose_new_tables` is tri-state: present → `Some(bool)`, absent → - // `None` (never false). A malformed override/value aborts the load rather than - // silently coercing to false. - const apiAutoExposeNewTables = yield* resolveOptionalBoolOrFail( - "api.auto_expose_new_tables", - remoteWins("api.auto_expose_new_tables") - ? undefined - : envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"), - apiRaw?.["auto_expose_new_tables"], - lookup, - ); - const apiSchemas = resolveStringSlice( - (remoteWins("api.schemas") ? undefined : envOverride("SUPABASE_API_SCHEMAS")) ?? - apiRaw?.["schemas"], - DEFAULT_API_SCHEMAS, - lookup, - ); - if (apiSchemas === undefined) { - return yield* new DbConfigLoadError({ - message: "failed to parse config: invalid api.schemas.", - }); - } + const schemaPaths = yield* getKey(CliConfigKeys.db.migrations.schemaPaths); + const seedEnabled = config.db.seed.enabled; + const seedSqlPaths = (yield* getKey(CliConfigKeys.db.seed.sqlPaths)).value; const values: DbTomlValues = { projectEnv, - apiSchemas, + apiSchemas: config.api.schemas, port, shadowPort, - password: passwordRaw !== undefined ? expandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, + password: (yield* getKey(CliConfigKeys.db.password)).value, poolerConnectionString, projectId, majorVersion, orioledbVersion, denoVersion, pgDelta: { - enabled, + enabled: (yield* getKey(CliConfigKeys.experimental.pgdelta.enabled)).value, declarativeSchemaPath, formatOptions, }, webhooksEnabled, baseline: { authEnabled, - storageEnabled: yield* resolveBoolOrFail( - "storage.enabled", - storageRaw?.["enabled"], - true, - lookup, - ), - realtimeEnabled: yield* resolveBoolOrFail( - "realtime.enabled", - realtimeRaw?.["enabled"], - true, - lookup, - ), - apiAutoExposeNewTables, + storageEnabled: config.storage.enabled, + realtimeEnabled: config.realtime.enabled, + apiAutoExposeNewTables: Option.fromNullishOr(config.api.auto_expose_new_tables), vaultNames, }, - migrationsEnabled, - schemaPaths, - schemaPathPatterns: schemaPathsResolved.patterns, + migrationsEnabled: config.db.migrations.enabled, + schemaPaths: schemaPaths.value, + schemaPathPatterns: schemaPaths.unnormalized ?? schemaPaths.value, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, - appliedRemote, - remoteOverrideKeys, + appliedRemote: Option.getOrUndefined(resolvedConfig.appliedRemote), }; return values; }); @@ -2253,9 +779,12 @@ export const readDbToml = ( ? readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets).pipe( // Fall back to the ignore-file defaults path (never re-reads the broken config) // so a best-effort caller gets a well-formed defaults result instead of a throw. - Effect.catchTag("DbConfigLoadError", () => - readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), - ), + Effect.catchTags({ + DbConfigLoadError: () => + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), + CliConfigValueError: () => + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), + }), ) : readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets); }; diff --git a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts index ac815c3469..a120c30cab 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts @@ -1,21 +1,38 @@ import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Config, ConfigProvider, Effect, Exit, FileSystem, Option, Path, Ref } from "effect"; +import { + Cause, + Config, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Ref, +} from "effect"; import type { PlatformError } from "effect/PlatformError"; -import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { checkDbToml, - loadProjectEnv, + loadProjectEnvValues, readDbToml, resolveDeclarativeDir, - resolveSeedSqlPath, + type DbTomlValues, } from "./db-config.toml-read.ts"; +import { resolveSeedSqlPath } from "../shared/config/seed-path.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; +import type { CliConfigValues } from "../config/cli-config-values.service.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, } from "../telemetry/command-telemetry-attributes.ts"; +const servicesLive = Layer.mergeAll(BunServices.layer, cliConfigValuesTestLayer); + function withConfig(content: string | undefined, poolerUrl?: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -47,43 +64,42 @@ const makeDir = (dir: string, segments: ReadonlyArray) => yield* fs.makeDirectory(path.join(dir, ...segments), { recursive: true }); }).pipe(Effect.provide(BunServices.layer)); -const withEnv = (values: Readonly>) => - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), - ); +const withEnv = + (values: Readonly>) => + (body: Effect.Effect) => + withConfigEnv(values, body); const read = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); const readRef = (workdir: string, ref: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readDbToml(fs, path, workdir, ref); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); const loadEnv = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* loadProjectEnv(fs, path, workdir); + return yield* loadProjectEnvValues(fs, path, workdir); }).pipe(Effect.provide(BunServices.layer)); describe("read (lenient) vs check (throws) split", () => { const withServices = ( dir: string, - run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* run(fs, path); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(servicesLive)); it.effect("checkDbToml throws on an undecryptable secret", () => { return withConfig('[db]\nroot_key = "encrypted:anything"\n').pipe( @@ -102,6 +118,20 @@ describe("read (lenient) vs check (throws) split", () => { ); }); + it.effect("checkDbToml surfaces an invalid env value as CliConfigValueError", () => { + return withConfig("[db]\nport = 5432\n").pipe( + Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PORT=notaport\n")), + Effect.flatMap((dir) => withServices(dir, (fs, path) => checkDbToml(fs, path, dir))), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error._tag).toBe("CliConfigValueError"); + expect(error.message).toContain('Invalid SUPABASE_DB_PORT="notaport"'); + }), + ), + ); + }); + it.effect("can skip vault resolution without skipping the rest of config validation", () => { return withConfig( [ @@ -149,7 +179,7 @@ describe("read (lenient) vs check (throws) split", () => { ), Effect.tap((v) => Effect.sync(() => { - expect(v.projectId).toEqual(Option.some("lenientproj")); + expect(v.projectId).toBe("lenientproj"); }), ), ); @@ -158,19 +188,17 @@ describe("read (lenient) vs check (throws) split", () => { describe("readDbToml", () => { it.effect("returns defaults when config.toml is absent", () => { - return withConfig(undefined).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.port).toBe(54322); - expect(v.shadowPort).toBe(54320); - expect(v.password).toBe("postgres"); - expect(Option.isNone(v.poolerConnectionString)).toBe(true); - expect(Option.isNone(v.projectId)).toBe(true); - expect(v.denoVersion).toBe(2); - }), - ), - ); + return Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* withConfig(undefined); + const v = yield* read(dir); + expect(v.port).toBe(54322); + expect(v.shadowPort).toBe(54320); + expect(v.password).toBe("postgres"); + expect(Option.isNone(v.poolerConnectionString)).toBe(true); + expect(v.projectId).toBe(sanitizeProjectId(path.basename(dir))); + expect(v.denoVersion).toBe(2); + }).pipe(Effect.provide(BunServices.layer)); }); // A known-good test vector: decrypts to "value" under the keypair below. @@ -179,39 +207,30 @@ describe("readDbToml", () => { "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; it.effect("decrypts an encrypted: [db.vault] secret when DOTENV_PRIVATE_KEY is set", () => { - return withEnvVar( - "DOTENV_PRIVATE_KEY", - VAULT_PRIVATE_KEY, - withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); - }), - ), - withEnv({}), + return withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); + }), ), + withEnv({ DOTENV_PRIVATE_KEY: VAULT_PRIVATE_KEY }), ); }); it.effect("fails the load for an encrypted: [db.vault] secret with no private key", () => { - return withEnvVar( - "DOTENV_PRIVATE_KEY", - undefined, - withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: missing private key", - ); - } - }), - ), - withEnv({}), + return withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "failed to parse config: missing private key", + ); + } + }), ), ); }); @@ -292,17 +311,6 @@ describe("readDbToml", () => { ); }); - it.effect("weakly coerces non-string db.seed.sql_paths array elements", () => { - return withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.seed.sqlPaths).toEqual(["supabase/42", "supabase/1", "supabase/seed.sql"]); - }), - ), - ); - }); - it.effect( "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", () => { @@ -357,71 +365,69 @@ describe("readDbToml", () => { }, ); - it.effect("weakly coerces non-string db.migrations.schema_paths array elements", () => { - // A bool coerces to "1"/"0" and a number to its decimal string rather than erroring or - // dropping the element: `schema_paths = [42, true, "schemas/*.sql"]` resolves to - // `supabase/{42,1,schemas/*.sql}`, not a filtered two-element list. - return withConfig( - ["[db.migrations]", 'schema_paths = [42, true, "schemas/*.sql"]', ""].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/42", "supabase/1", "supabase/schemas/*.sql"]); - }), - ), - ); - }); - - it.effect( - "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation", - () => { - return withConfig(["[db.migrations]", "schema_paths = [1e21]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/1000000000000000000000"]); - }), - ), - ); + it.effect.each([ + { + name: "seed array with number and bool", + table: "db.seed", + field: "sql_paths", + value: '[42, true, "seed.sql"]', + expected: ["supabase/42", "supabase/1", "supabase/seed.sql"], }, - ); - - it.effect( - "formats TOML special-float db.migrations.schema_paths entries as +Inf/-Inf/NaN, not JS's toString", - () => { - return withConfig(["[db.migrations]", "schema_paths = [inf, -inf, nan]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/+Inf", "supabase/-Inf", "supabase/NaN"]); - }), - ), - ); + { + name: "schema array with number and bool", + table: "db.migrations", + field: "schema_paths", + value: '[42, true, "schemas/*.sql"]', + expected: ["supabase/42", "supabase/1", "supabase/schemas/*.sql"], }, - ); - - it.effect("weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths", () => { - return Effect.all([ - withConfig(["[db.migrations]", "schema_paths = 42", ""].join("\n")), - withConfig(["[db.migrations]", "schema_paths = true", ""].join("\n")), - ]).pipe( - Effect.flatMap(([dirNumber, dirBool]) => Effect.all([read(dirNumber), read(dirBool)])), - Effect.tap(([numberResult, boolResult]) => - Effect.sync(() => { - expect(numberResult.schemaPaths).toEqual(["supabase/42"]); - expect(boolResult.schemaPaths).toEqual(["supabase/1"]); - }), - ), - ); - }); - - it.effect("treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns", () => { - return withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")).pipe( + { + name: "large number as fixed decimal", + table: "db.migrations", + field: "schema_paths", + value: "[1e21]", + expected: ["supabase/1000000000000000000000"], + }, + { + name: "special floats", + table: "db.migrations", + field: "schema_paths", + value: "[inf, -inf, nan]", + expected: ["supabase/+Inf", "supabase/-Inf", "supabase/NaN"], + }, + { + name: "scalar number schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "42", + expected: ["supabase/42"], + }, + { + name: "scalar bool schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "true", + expected: ["supabase/1"], + }, + { + name: "empty table schema_paths", + table: "db.migrations", + field: "schema_paths", + value: "{}", + expected: [], + }, + { + name: "scalar number sql_paths", + table: "db.seed", + field: "sql_paths", + value: "42", + expected: ["supabase/42"], + }, + ])("weakly coerces a glob list: $name", ({ table, field, value, expected }) => { + return withConfig([`[${table}]`, `${field} = ${value}`, ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.schemaPaths).toEqual([]); + expect(table === "db.seed" ? v.seed.sqlPaths : v.schemaPaths).toEqual(expected); }), ), ); @@ -433,7 +439,7 @@ describe("readDbToml", () => { { name: "local date", literal: "1979-05-27", typeName: "local date" }, { name: "local time", literal: "07:32:00", typeName: "local time" }, ])( - "aborts the whole config load on a TOP-LEVEL bare $name db.migrations.schema_paths instead of silently treating it as empty", + "rejects a bare $name db.migrations.schema_paths instead of treating it as empty", ({ literal, typeName }) => { // `smol-toml` parses every TOML datetime variant to a `TomlDate` (a `Date` subclass) // that stores its value internally, not as an enumerable own property, so @@ -458,237 +464,216 @@ describe("readDbToml", () => { }, ); - it.effect( - "aborts the whole config load on a bare datetime db.migrations.schema_paths ARRAY element", - () => { - // Same `TomlDate`-vs-generic-object collision as the top-level scalar case above, but - // reached through the real-array branch instead of the scalar fallback: the valid glob - // entry must never mask the datetime's failure. - return withConfig( - ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""].join( - "\n", - ), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "db.migrations.schema_paths[1]: expected a string, got offset date-time", - ); - } - }), - ), - ); + it.effect.each([ + { + name: "a datetime schema_paths array element", + toml: ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""], + issue: "db.migrations.schema_paths[1]: expected a string, got offset date-time", }, - ); - - it.effect( - "aborts the whole config load on a TOP-LEVEL bare datetime db.seed.sql_paths (same UnmarshalExact call as schema_paths, review CLI-1958)", - () => { - return withConfig(["[db.seed]", "sql_paths = 1979-05-27T07:32:00Z", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "db.seed.sql_paths[0]: expected a string, got offset date-time", - ); - } - }), - ), - ); + { + name: "a bare datetime sql_paths", + toml: ["[db.seed]", "sql_paths = 1979-05-27T07:32:00Z", ""], + issue: "db.seed.sql_paths[0]: expected a string, got offset date-time", }, - ); - - it.effect( - "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (synthetic index 0)", - () => { - // A non-empty map isn't weakly coercible, so it fails decoding element 0 the same way a - // nested-array/table array element does. - return withConfig(["[db.migrations.schema_paths]", 'foo = "bar"', ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "db.migrations.schema_paths[0]: expected a string, got table", - ); - } - }), - ), - ); + { + name: "a table schema_paths", + toml: ["[db.migrations.schema_paths]", 'foo = "bar"', ""], + issue: "db.migrations.schema_paths[0]: expected a string, got table", }, - ); - - it.effect( - "weakly coerces a TOP-LEVEL scalar db.seed.sql_paths instead of falling back to the ['seed.sql'] default", - () => { - // The absent-key default (`["seed.sql"]`) only applies when the key is missing entirely; - // a present scalar still goes through the weak-decode wrap, same as schema_paths above. - return withConfig(["[db.seed]", "enabled = true", "sql_paths = 42", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.seed.sqlPaths).toEqual(["supabase/42"]); - }), - ), - ); + { + name: "a nested-list schema_paths element", + toml: ["[db.migrations]", "schema_paths = [[]]", ""], + issue: "failed to parse config:\ndb.migrations.schema_paths[0]: expected a string, got array", }, - ); - - it.effect( - "aborts the whole config load on a non-scalar db.migrations.schema_paths element", - () => { - // Unlike a bool/number (weakly coerced above), a nested array/table fails the whole - // config load rather than dropping just that element. - return withConfig(["[db.migrations]", "schema_paths = [[]]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config:\ndb.migrations.schema_paths[0]: expected a string, got array", - ); - } - }), - ), - ); + { + name: "a table schema_paths element", + toml: ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""], + issue: "db.migrations.schema_paths[1]: expected a string, got table", }, - ); - - it.effect( - "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index", - () => { - return withConfig( - ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""].join("\n"), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "db.migrations.schema_paths[1]: expected a string, got table", - ); - } - }), - ), - ); + { + name: "a nested-list sql_paths element", + toml: ["[db.seed]", "sql_paths = [[]]", ""], + issue: "db.seed.sql_paths[0]: expected a string, got array", }, - ); + ])("rejects $name", ({ toml, issue }) => { + return withConfig(toml.join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(issue); + }), + ), + ); + }); - it.effect( - "aborts the whole config load on a non-scalar db.seed.sql_paths element (same UnmarshalExact call as schema_paths)", - () => { - return withConfig(["[db.seed]", "sql_paths = [[]]", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "db.seed.sql_paths[0]: expected a string, got array", - ); - } - }), - ), - ); - }, - ); + it.effect("reports invalid entries of both glob lists in one error, schema_paths first", () => { + return withConfig( + ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( + "\n", + ), + ).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = Cause.pretty(exit.cause); + const schemaIssue = "db.migrations.schema_paths[0]: expected a string, got array"; + const seedIssue = "db.seed.sql_paths[0]: expected a string, got array"; + expect(message).toContain(schemaIssue); + expect(message).toContain(seedIssue); + expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); + } + }), + ), + ); + }); - it.effect( - "aggregates non-string-entry issues from BOTH db.seed.sql_paths and db.migrations.schema_paths in one error", - () => { - return withConfig( - ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( - "\n", - ), - ).pipe( - Effect.flatMap(read), - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const message = Cause.pretty(exit.cause); - const schemaIssue = "db.migrations.schema_paths[0]: expected a string, got array"; - const seedIssue = "db.seed.sql_paths[0]: expected a string, got array"; - expect(message).toContain(schemaIssue); - expect(message).toContain(seedIssue); - expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); - } - }), - ), - ); + it.effect.each([ + { + name: "numeric db.seed.enabled = 0", + table: "db.seed", + field: "enabled", + value: "0", + read: (v: DbTomlValues) => v.seed.enabled, + expected: false, }, - ); + { + name: "numeric db.migrations.enabled = 0", + table: "db.migrations", + field: "enabled", + value: "0", + read: (v: DbTomlValues) => v.migrationsEnabled, + expected: false, + }, + { + name: "numeric experimental.pgdelta.enabled = 1", + table: "experimental.pgdelta", + field: "enabled", + value: "1", + read: (v: DbTomlValues) => v.pgDelta.enabled, + expected: true, + }, + { + name: 'string auth.enabled = "0"', + table: "auth", + field: "enabled", + value: '"0"', + read: (v: DbTomlValues) => v.baseline.authEnabled, + expected: false, + }, + { + name: 'upper-case api.auto_expose_new_tables = "TRUE"', + table: "api", + field: "auto_expose_new_tables", + value: '"TRUE"', + read: (v: DbTomlValues) => Option.getOrNull(v.baseline.apiAutoExposeNewTables), + expected: true, + }, + ])("decodes $name as a bool", ({ table, field, value, read: pick, expected }) => { + return withConfig([`[${table}]`, `${field} = ${value}`, ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(pick(v)).toBe(expected); + }), + ), + ); + }); - it.effect( - "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", - () => { - const ref = "schmschmschmschmschm"; - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - 'db.migrations.schema_paths = ["remote-only.sql"]', - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), + it.effect.each([ + { + name: "db.seed.enabled", + table: "db.seed", + field: "enabled", + read: (v: DbTomlValues) => v.seed.enabled, + }, + { + name: "db.migrations.enabled", + table: "db.migrations", + field: "enabled", + read: (v: DbTomlValues) => v.migrationsEnabled, + }, + { + name: "experimental.pgdelta.enabled", + table: "experimental.pgdelta", + field: "enabled", + read: (v: DbTomlValues) => v.pgDelta.enabled, + }, + { + name: "api.auto_expose_new_tables", + table: "api", + field: "auto_expose_new_tables", + read: (v: DbTomlValues) => Option.getOrNull(v.baseline.apiAutoExposeNewTables), + }, + ])( + "expands an env() reference in $name through the shell and project .env", + ({ table, field, read: pick }) => { + return withConfig([`[${table}]`, `${field} = "env(WEAK_FLAG)"`, ""].join("\n")).pipe( + Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/remote-only.sql"]); + expect(pick(v)).toBe(true); }), ), - withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-only.sql" }), + withEnv({ WEAK_FLAG: "TRUE" }), ); }, ); - it.effect("decodes a numeric db.seed.enabled = 0 as false", () => { - return withConfig(["[db.seed]", "enabled = 0", ""].join("\n")).pipe( + it.effect("fails on an unresolved env() reference in a bool field", () => { + return withConfig(["[db.seed]", 'enabled = "env(WEAK_FLAG_UNSET)"', ""].join("\n")).pipe( Effect.flatMap(read), - Effect.tap((v) => + Effect.exit, + Effect.tap((exit) => Effect.sync(() => { - expect(v.seed.enabled).toBe(false); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Invalid db.seed.enabled in supabase/config.toml", + ); + } }), ), ); }); - it.effect("decodes a numeric db.migrations.enabled = 0 as false", () => { - return withConfig(["[db.migrations]", "enabled = 0", ""].join("\n")).pipe( + it.effect("decodes a numeric string db.port", () => { + return withConfig(["[db]", 'port = "6002"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(v.migrationsEnabled).toBe(false); + expect(v.port).toBe(6002); }), ), ); }); - it.effect("decodes a numeric experimental.pgdelta.enabled = 1 as true", () => { - return withConfig(["[experimental.pgdelta]", "enabled = 1", ""].join("\n")).pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); - }), - ), - ); - }); + it.effect( + "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS beats a matched remote's db.migrations.schema_paths", + () => { + const ref = "schmschmschmschmschm"; + return withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + 'db.migrations.schema_paths = ["remote-only.sql"]', + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/env-only.sql"]); + }), + ), + withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-only.sql" }), + ); + }, + ); it.effect("rejects an explicit db.port = 0 as a missing required field", () => { return withConfig(["[db]", "port = 0", ""].join("\n")).pipe( @@ -705,8 +690,7 @@ describe("readDbToml", () => { ); }); - it.effect("an explicit remote db.migrations.enabled beats SUPABASE_DB_MIGRATIONS_ENABLED", () => { - // A matched remote's keys override the environment, so an explicit remote value wins over the env var. + it.effect("SUPABASE_DB_MIGRATIONS_ENABLED beats a matched remote's db.migrations.enabled", () => { const ref = "abcdefghijklmnopqrst"; return withConfig( ["[remotes.prod]", `project_id = "${ref}"`, "db.migrations.enabled = true", ""].join("\n"), @@ -714,7 +698,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.migrationsEnabled).toBe(true); + expect(v.migrationsEnabled).toBe(false); }), ), withEnv({ SUPABASE_DB_MIGRATIONS_ENABLED: "false" }), @@ -782,9 +766,8 @@ describe("readDbToml", () => { }); it.effect( - "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", + "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS beats a matched remote's db.migrations.schema_paths in a [remotes.*.db.migrations] table", () => { - // Same override-tier precedence as db.migrations.enabled above. const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -798,7 +781,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/remote-wins.sql"]); + expect(v.schemaPaths).toEqual(["supabase/env-wins.sql"]); }), ), withEnv({ SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-wins.sql" }), @@ -806,26 +789,29 @@ describe("readDbToml", () => { }, ); - it.effect("an explicit remote experimental.pgdelta.enabled beats its SUPABASE_* env var", () => { - const ref = "abcdefghijklmnopqrst"; - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); - }), - ), - withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }), - ); - }); + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED beats a matched remote's pgdelta.enabled", + () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.pgDelta.enabled).toBe(false); + }), + ), + withEnv({ SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }), + ); + }, + ); it.effect("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED still wins when the block omits pgdelta", () => { const ref = "abcdefghijklmnopqrst"; @@ -840,7 +826,7 @@ describe("readDbToml", () => { ); }); - it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { + it.effect("SUPABASE_AUTH_ENABLED beats a matched remote's auth.enabled", () => { const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -854,7 +840,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.baseline.authEnabled).toBe(false); + expect(v.baseline.authEnabled).toBe(true); }), ), withEnv({ SUPABASE_AUTH_ENABLED: "true" }), @@ -875,11 +861,8 @@ describe("readDbToml", () => { }); it.effect( - "an explicit remote experimental.webhooks.enabled beats its SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED env var", + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED=false beats a matched remote's enabled = true and fails validation", () => { - // Without this precedence, the suppressed env value would win, and the merged - // [experimental.webhooks] section (present via the remote block) would then fail - // validation ("Webhooks cannot be deactivated"). const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -894,8 +877,10 @@ describe("readDbToml", () => { Effect.exit, Effect.tap((exit) => Effect.sync(() => { - expect(Exit.isSuccess(exit)).toBe(true); - if (Exit.isSuccess(exit)) expect(exit.value.webhooksEnabled).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("Webhooks cannot be deactivated"); + } }), ), withEnv({ SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "false" }), @@ -983,14 +968,13 @@ describe("readDbToml", () => { ); }); - it.effect("a remote block forcing db.seed.enabled=false beats SUPABASE_DB_SEED_ENABLED", () => { - // A remote block that omits db.seed.enabled stays unseeded even with the env var set. + it.effect("SUPABASE_DB_SEED_ENABLED beats the seed default a matched remote implies", () => { const ref = "abcdefghijklmnopqrst"; return withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.seed.enabled).toBe(false); + expect(v.seed.enabled).toBe(true); }), ), withEnv({ SUPABASE_DB_SEED_ENABLED: "true" }), @@ -1315,18 +1299,6 @@ describe("readDbToml", () => { ); }); - it.effect("parses [api] auto_expose_new_tables string with bool tokens (TRUE → true)", () => { - // `TRUE`/`1`/`t` are also accepted as true, not just lowercase `true`. - return withConfig('[api]\nauto_expose_new_tables = "TRUE"\n').pipe( - Effect.flatMap(read), - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.baseline.apiAutoExposeNewTables)).toBe(true); - }), - ), - ); - }); - it.effect("decodes empty api schemas while keeping auto_expose_new_tables absent", () => { return withConfig('[api]\nschemas = ""\n').pipe( Effect.flatMap(read), @@ -1348,9 +1320,9 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("DbConfigLoadError"); + expect(causeText).toContain("CliConfigValueError"); expect(causeText).toContain( - "failed to parse config: invalid api.auto_expose_new_tables.", + "Invalid api.auto_expose_new_tables in supabase/config.toml", ); } }), @@ -1423,7 +1395,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: invalid experimental.pgdelta.enabled: maybe.", + "Invalid SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", ); } }), @@ -1432,20 +1404,21 @@ describe("readDbToml", () => { ); }); - it.effect("parses [auth] enabled string forms via bool parsing and fails on malformed", () => { - return Effect.gen(function* () { - const ok = yield* withConfig(["[auth]", 'enabled = "0"', ""].join("\n")); - const bad = yield* withConfig(["[storage]", 'enabled = "nope"', ""].join("\n")); - const v = yield* read(ok); - expect(v.baseline.authEnabled).toBe(false); // "0" → false - const exit = yield* read(bad).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "failed to parse config: invalid storage.enabled.", - ); - } - }); + it.effect("fails on a malformed [storage] enabled string", () => { + return withConfig(["[storage]", 'enabled = "nope"', ""].join("\n")).pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Invalid storage.enabled in supabase/config.toml", + ); + } + }), + ), + ); }); it.effect("fails with DbConfigLoadError when config.toml is present but unreadable", () => { @@ -1497,13 +1470,34 @@ describe("readDbToml", () => { expect(v.port).toBe(55555); expect(v.shadowPort).toBe(55556); expect(v.password).toBe("hunter2"); - expect(Option.getOrNull(v.projectId)).toBe("my-project"); + expect(v.projectId).toBe("my-project"); expect(Option.getOrNull(v.poolerConnectionString)).toContain("postgres.ref"); }), ), ); }); + it.effect("reads the db subtree and project_id from supabase/config.json", () => { + return withConfig(undefined).pipe( + Effect.tap((dir) => makeDir(dir, ["supabase"])), + Effect.tap((dir) => + writeFile( + dir, + ["supabase", "config.json"], + JSON.stringify({ project_id: "json-project", db: { port: 55777, shadow_port: 55778 } }), + ), + ), + Effect.flatMap(read), + Effect.tap((v) => + Effect.sync(() => { + expect(v.port).toBe(55777); + expect(v.shadowPort).toBe(55778); + expect(v.projectId).toBe("json-project"); + }), + ), + ); + }); + it.effect("expands env(VAR) for password and port", () => { return withConfig( ["[db]", 'port = "env(DB_PORT)"', 'password = "env(DB_PW)"', ""].join("\n"), @@ -1585,7 +1579,7 @@ describe("readDbToml", () => { Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("abcdefghijklmnopqrst"); + expect(v.projectId).toBe("abcdefghijklmnopqrst"); }), ), withEnv({ PROJECT_REF: "abcdefghijklmnopqrst" }), @@ -1754,10 +1748,8 @@ describe("readDbToml", () => { ); it.effect( - "a matched remote's legacy experimental.orioledb_version still beats a conflicting SUPABASE_DB_ORIOLEDB_VERSION", + "SUPABASE_DB_ORIOLEDB_VERSION beats a matched remote's legacy experimental.orioledb_version", () => { - // Same precedence as any other `ENV_OVERRIDABLE_KEYS` field (e.g. db.major_version): - // an explicit remote value beats its matching `SUPABASE_*` env override. const ref = "abcdefghijklmnopqrst"; return withConfig( [ @@ -1774,7 +1766,7 @@ describe("readDbToml", () => { Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + expect(Option.getOrNull(v.orioledbVersion)).toBe("env-value"); }), ), withEnv({ SUPABASE_DB_ORIOLEDB_VERSION: "env-value" }), @@ -1840,7 +1832,7 @@ describe("readDbToml", () => { return yield* readDbToml(fs, path, dir, undefined, { warnOnUnresolvedEnv: false }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(servicesLive), Effect.tap((v) => Effect.sync(() => { // Config load still succeeds and still resolves the value; only the @@ -1879,8 +1871,8 @@ describe("readDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError"); - expect(Cause.pretty(exit.cause)).toContain("invalid db.port"); + expect(Cause.pretty(exit.cause)).toContain("CliConfigValueError"); + expect(Cause.pretty(exit.cause)).toContain("Invalid db.port"); } }), ), @@ -1889,7 +1881,7 @@ describe("readDbToml", () => { }, ); - it.effect("fails when a present shadow_port is not a valid port number", () => { + it.effect("fails when a present shadow_port cannot unmarshal into a uint16", () => { return withConfig(["[db]", "port = 5000", 'shadow_port = "nope"', ""].join("\n")).pipe( Effect.flatMap(read), Effect.exit, @@ -1897,7 +1889,7 @@ describe("readDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("invalid db.shadow_port"); + expect(Cause.pretty(exit.cause)).toContain("Invalid db.shadow_port"); } }), ), @@ -1920,7 +1912,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets the shell env win over a project .env value (no override)", () => { + it.effect("lets the shell env win over a project .env value (godotenv no-override)", () => { return withConfig(["[db]", 'password = "env(DB_FILEVAR)"', ""].join("\n")).pipe( Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], "DB_FILEVAR=from-file\n")), Effect.flatMap(read), @@ -2082,7 +2074,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "Failed reading config: Invalid db.major_version: 17foo.", + 'Invalid db.major_version in supabase/config.toml: "17foo" is not a non-negative integer.', ); } }), @@ -2135,7 +2127,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "Failed reading config: Invalid edge_runtime.deno_version: 2foo.", + 'Invalid edge_runtime.deno_version in supabase/config.toml: "2foo" is not a non-negative integer.', ); } }), @@ -2185,25 +2177,28 @@ describe("readDbToml", () => { ); }); - it.effect("loadProjectEnv surfaces SUPABASE_DB_PASSWORD from .env (linked-path source)", () => { - // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value - // defined only in supabase/.env must be visible. - return withConfig(undefined).pipe( - Effect.tap((dir) => makeDir(dir, ["supabase"])), - Effect.tap((dir) => - writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PASSWORD=from-dotenv\n"), - ), - Effect.flatMap(loadEnv), - Effect.tap((env) => - Effect.sync(() => { - expect(env["SUPABASE_DB_PASSWORD"]).toBe("from-dotenv"); - }), - ), - withEnv({}), - ); - }); + it.effect( + "loadProjectEnvValues surfaces SUPABASE_DB_PASSWORD from .env (linked-path source)", + () => { + // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value + // defined only in supabase/.env must be visible. + return withConfig(undefined).pipe( + Effect.tap((dir) => makeDir(dir, ["supabase"])), + Effect.tap((dir) => + writeFile(dir, ["supabase", ".env"], "SUPABASE_DB_PASSWORD=from-dotenv\n"), + ), + Effect.flatMap(loadEnv), + Effect.tap((env) => + Effect.sync(() => { + expect(env["SUPABASE_DB_PASSWORD"]).toBe("from-dotenv"); + }), + ), + withEnv({}), + ); + }, + ); - it.effect("loadProjectEnv is pure: returns every key and never touches process.env", () => { + it.effect("loadProjectEnvValues is pure: returns every key and never touches process.env", () => { // A mere load for SUPABASE_YES has no global side effect. const processEnvValues = Effect.forEach( ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"], @@ -2505,6 +2500,12 @@ describe("readDbToml auth.Enabled validation", () => { "Invalid config for auth.email.template.invite.content: please use content_path instead", ), ); + it.effect("rejects an email template whose content comes from the shell environment", () => + failsWith( + ["[auth.email.template.invite]", 'subject = "Welcome"'], + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ).pipe(withEnv({ SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT: "

hi

" })), + ); it.effect("rejects an email template whose content_path file is missing", () => failsWith( ["[auth.email.template.invite]", 'content_path = "./missing.html"'], @@ -2525,6 +2526,23 @@ describe("readDbToml auth.Enabled validation", () => { "Missing required field in config: auth.external.github.client_id", ), ); + it.effect("rejects an enabled custom external provider without a client_id", () => + failsWith( + ["[auth.external.my_oidc]", "enabled = true"], + "Missing required field in config: auth.external.my_oidc.client_id", + ), + ); + it.effect("rejects an enabled custom external provider without a secret", () => + failsWith( + ["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"'], + "Missing required field in config: auth.external.my_oidc.secret", + ), + ); + it.effect("accepts a custom external provider whose secret comes from the shell", () => + succeeds(["[auth.external.my_oidc]", "enabled = true", 'client_id = "cid"']).pipe( + withEnv({ SUPABASE_AUTH_EXTERNAL_MY_OIDC_SECRET: "shh" }), + ), + ); it.effect("exempts apple/google from the external secret requirement", () => succeeds(["[auth.external.apple]", "enabled = true", 'client_id = "a"']), ); @@ -2600,14 +2618,14 @@ describe("readDbToml auth.Enabled validation", () => { it.effect("fails on a malformed auth boolean string instead of coercing to false", () => failsWith( ["[auth.passkey]", 'enabled = "maybe"'], - "failed to parse config: invalid auth.passkey.enabled.", + "Invalid auth.passkey.enabled in supabase/config.toml", ), ); it.effect("rejects an unknown captcha provider, regardless of enabled", () => failsWith( ["[auth.captcha]", "enabled = false", 'provider = "cloudflare"'], - "auth.captcha.provider: must be one of hcaptcha, turnstile", + "Invalid auth.captcha.provider in supabase/config.toml", ), ); }); @@ -2703,7 +2721,7 @@ describe("readDbToml non-scalar config booleans", () => { const exit = yield* read(dir).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain(`failed to parse config: invalid ${field}.`); + expect(Cause.pretty(exit.cause)).toContain(`Invalid ${field} in supabase/config.toml`); } }); it.effect("rejects an array value for [db.migrations] enabled", () => @@ -2714,6 +2732,60 @@ describe("readDbToml non-scalar config booleans", () => { ); }); +describe("readDbToml project env scoping", () => { + const LINKED = "linkedlinkedlinkedlin"; + const OTHER = "otherotherotherother"; + + const withEnvFile = (config: string, dotEnv: string, linkedRef?: string) => + withConfig(config).pipe( + Effect.tap((dir) => writeFile(dir, ["supabase", ".env"], dotEnv)), + Effect.tap((dir) => + linkedRef === undefined + ? Effect.void + : makeDir(dir, ["supabase", ".temp"]).pipe( + Effect.andThen(writeFile(dir, ["supabase", ".temp", "project-ref"], linkedRef)), + ), + ), + ); + + it.effect("omits a linked project's .env credential when targeting another project", () => { + return withEnvFile( + 'project_id = "demo"\n', + "SUPABASE_DB_PASSWORD=linked-secret\nUNRELATED=kept\n", + LINKED, + ).pipe( + Effect.flatMap((dir) => readRef(dir, OTHER)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectEnv).toEqual({ UNRELATED: "kept" }); + }), + ), + ); + }); + + it.effect("keeps the .env credential when targeting the linked project", () => { + return withEnvFile( + 'project_id = "demo"\n', + "SUPABASE_DB_PASSWORD=linked-secret\n", + LINKED, + ).pipe( + Effect.flatMap((dir) => readRef(dir, LINKED)), + Effect.tap((v) => + Effect.sync(() => { + expect(v.projectEnv).toEqual({ SUPABASE_DB_PASSWORD: "linked-secret" }); + }), + ), + ); + }); + + it.effect("lets an empty shell value shadow the .env value an env() secret refers to", () => { + return withEnvFile( + '[db]\nroot_key = "env(SHADOWED_ROOT_KEY)"\n', + "SHADOWED_ROOT_KEY=encrypted:undecryptable\n", + ).pipe(Effect.flatMap(read), withEnv({ SHADOWED_ROOT_KEY: "" })); + }); +}); + describe("readDbToml empty project_id", () => { it.effect("rejects a present-but-empty top-level project_id", () => { return withConfig('project_id = ""\n').pipe( @@ -2762,7 +2834,7 @@ describe("readDbToml [analytics] validation", () => { it.effect("rejects an unknown analytics.backend regardless of enabled", () => failsWith( ["[analytics]", "enabled = false", 'backend = "clickhouse"'], - "analytics.backend: must be one of postgres, bigquery", + "Invalid analytics.backend in supabase/config.toml", ), ); it.effect("rejects bigquery analytics missing gcp_project_id", () => @@ -2822,7 +2894,7 @@ describe("readDbToml SUPABASE_PROJECT_ID override", () => { Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("env-project"); + expect(v.projectId).toBe("env-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "env-project" }), @@ -2834,7 +2906,7 @@ describe("readDbToml SUPABASE_PROJECT_ID override", () => { Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("env-project"); + expect(v.projectId).toBe("env-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "env-project" }), @@ -2846,135 +2918,42 @@ describe("readDbToml SUPABASE_PROJECT_ID override", () => { Effect.flatMap(read), Effect.tap((v) => Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("toml-project"); + expect(v.projectId).toBe("toml-project"); }), ), withEnv({ SUPABASE_PROJECT_ID: "" }), ); }); - it.effect( - "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", - () => { - const ref = "abcdefghijklmnopqrst"; - return withConfig( - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("project_id")).toBe(true); - expect(Option.getOrNull(v.projectId)).toBe(ref); - }), - ), - withEnv({ SUPABASE_PROJECT_ID: "local" }), - ); - }, - ); - - it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + it.effect("SUPABASE_PROJECT_ID beats a matched [remotes.]'s project_id", () => { const ref = "abcdefghijklmnopqrst"; - return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBeUndefined(); - expect(Option.getOrNull(v.projectId)).toBe("env-project"); - }), - ), - withEnv({ SUPABASE_PROJECT_ID: "env-project" }), - ); - }); -}); - -describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.template/notification", () => { - const ref = "abcdefghijklmnopqrst"; - - it.effect("tracks auth.captcha.provider when a matched remote block supplies it", () => { - // `provider` is a plain string leaf, not part of a dynamically-keyed section, so it must be - // tracked via `ENV_OVERRIDABLE_KEYS` like any other fixed-name field. return withConfig( - [ - "[auth.captcha]", - 'provider = "hcaptcha"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.captcha]", - 'provider = "turnstile"', - "", - ].join("\n"), + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), ).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("auth.captcha.provider")).toBe(true); + expect(v.projectId).toBe("local"); }), ), + withEnv({ SUPABASE_PROJECT_ID: "local" }), ); }); - it.effect("tracks a matched remote block's auth.email.template. leaves dynamically", () => { - // `auth.email.template..*` is an arbitrarily-keyed map, same shape as - // `auth.external..*`, so it must be flattened dynamically instead of relying on a - // fixed `ENV_OVERRIDABLE_KEYS` entry. - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.email.template.invite]", - 'content_path = "remote-invite.html"', - "", - ].join("\n"), - ).pipe( - // Template `content_path` resolves relative to the project root (`workdir`, i.e. `dir`). - Effect.tap((dir) => writeFile(dir, ["remote-invite.html"], "")), + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig(['project_id = "toml-project"', ""].join("\n")).pipe( Effect.flatMap((dir) => readRef(dir, ref)), Effect.tap((v) => Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect(v.remoteOverrideKeys.has("auth.email.template.invite.content_path")).toBe(true); - expect(v.remoteOverrideKeys.has("auth.email.template.invite.subject")).toBe(false); + expect(v.appliedRemote).toBeUndefined(); + expect(v.projectId).toBe("env-project"); }), ), + withEnv({ SUPABASE_PROJECT_ID: "env-project" }), ); }); - - it.effect( - "tracks a matched remote block's auth.email.notification. leaves dynamically", - () => { - // Sibling case to auth.email.template, including a boolean leaf (`enabled`). - return withConfig( - [ - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.auth.email.notification.password_changed]", - "enabled = true", - 'content_path = "remote-pw-changed.html"', - "", - ].join("\n"), - ).pipe( - // Notification `content_path` resolves relative to the project root, like a template. - Effect.tap((dir) => writeFile(dir, ["remote-pw-changed.html"], "")), - Effect.flatMap((dir) => readRef(dir, ref)), - Effect.tap((v) => - Effect.sync(() => { - expect(v.appliedRemote).toBe("prod"); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.enabled"), - ).toBe(true); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.content_path"), - ).toBe(true); - expect( - v.remoteOverrideKeys.has("auth.email.notification.password_changed.subject"), - ).toBe(false); - }), - ), - ); - }, - ); }); describe("readDbToml OrioleDB telemetry", () => { diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 6307d18a0e..9f7914dc0c 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -26,9 +26,9 @@ export interface DbConfigFlags { */ readonly resolveVaultSecrets?: boolean; /** - * The `--password`/`-p` flag value. When `Some`, it takes precedence over the - * `SUPABASE_DB_PASSWORD` env var on the linked path. Commands without a `--password` flag - * (e.g. `test db`) omit it, and the resolver falls back to env only. + * The `--password`/`-p` flag value, passed so the resolver can reject it with `--db-url` or + * `--local`. The linked path reads the password itself through `CliConfigValues`, where the + * flag beats `SUPABASE_DB_PASSWORD`. */ readonly password?: Option.Option; /** @@ -41,10 +41,9 @@ export interface DbConfigFlags { readonly linkedProjectRef?: Option.Option; /** * Marks `linkedProjectRef` as an ad-hoc remote target (e.g. `gen types --project-id `) - * rather than the current linked workdir, so the resolver must not reuse workdir-scoped - * credentials or cached state for it: it ignores the ambient `SUPABASE_DB_PASSWORD` and skips - * the saved `.temp/pooler-url`, fetching pooler config from the Management API instead. Absent - * for the normal `--linked` path, which may reuse both. + * rather than the current linked workdir, so the resolver must not reuse the saved + * `.temp/pooler-url`, fetching pooler config from the Management API instead. Absent for the + * normal `--linked` path, which may reuse it. */ readonly adHocProjectRef?: boolean; } diff --git a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts new file mode 100644 index 0000000000..4aac1598e0 --- /dev/null +++ b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts @@ -0,0 +1,131 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { rootCommandForFeatures } from "../cli/root.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { walkCommandTree, type WalkedCommand } from "../../tests/helpers/command-tree.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, + processEnvLayer, +} from "../../tests/helpers/mocks.ts"; +import { choiceKeysOf } from "../docs/docs-introspection.ts"; +import { unwrapParam } from "./param-introspection.ts"; + +const root = rootCommandForFeatures(); + +const hasFlag = (entry: WalkedCommand, name: string) => + entry.flags.some((flag) => flag.name === name); + +const isTombstone = (entry: WalkedCommand) => + entry.command.description?.startsWith("Removed:") === true; + +const passwordCommands = walkCommandTree(root).filter( + (entry) => hasFlag(entry, "password") && !isTombstone(entry), +); + +const withSelector = (selector: "db-url" | "local") => + passwordCommands.filter((entry) => hasFlag(entry, selector)); + +const requiredFlagArgs = (entry: WalkedCommand): ReadonlyArray => + entry.flags.flatMap((flag) => { + const unwrapped = unwrapParam(flag.param); + const required = + unwrapped !== undefined && + !unwrapped.isOptional && + !(unwrapped.isVariadic && unwrapped.variadicMin === 0) && + unwrapped.single.primitiveType._tag !== "Boolean"; + if (!required) return []; + const [firstChoice] = choiceKeysOf(unwrapped.single.primitiveType) ?? []; + return [`--${flag.name}`, firstChoice ?? "value"]; + }); + +const selectorArgs = (selector: "db-url" | "local" | "none") => + selector === "db-url" + ? ["--db-url", "postgres://user:secret@127.0.0.1:1/postgres"] + : selector === "local" + ? ["--local"] + : []; + +const runCommand = (entry: WalkedCommand, selector: "db-url" | "local" | "none") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-password-rejection-" }); + const args = [ + ...entry.path, + ...requiredFlagArgs(entry), + "--password", + "from-flag", + ...selectorArgs(selector), + "--workdir", + home, + "--experimental", + ]; + const exit = yield* Command.runWith(root, { version: "0.0.0-test" })(args).pipe( + Effect.provide( + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + mockOutput({ format: "text" }).layer, + BunServices.layer, + mockRuntimeInfo(), + mockAnalytics().layer, + mockTelemetryRuntime(), + mockTty(), + mockProcessControl().layer, + processEnvLayer({ SUPABASE_HOME: home }), + ), + ), + Effect.exit, + ); + return Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +describe("--password with a direct database target", () => { + it("is discovered on commands that accept a direct target", () => { + expect(passwordCommands.length).toBeGreaterThan(5); + expect(withSelector("db-url").length).toBeGreaterThan(5); + expect(withSelector("local").length).toBeGreaterThan(5); + }); + + for (const selector of ["db-url", "local"] as const) { + for (const entry of withSelector(selector)) { + it.effect(`rejects ${entry.path.join(" ")} --password with --${selector}`, () => + Effect.gen(function* () { + const failure = yield* runCommand(entry, selector); + + expect(failure).toMatchObject({ + _tag: "DbPasswordFlagsError", + message: + selector === "db-url" + ? "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" + : "--password can't be used with --local. The local database uses [db].password from supabase/config.toml.", + }); + }), + ); + } + } + + it.effect("explains that migration squash defaults to local when --password has no target", () => + Effect.gen(function* () { + const entry = passwordCommands.find( + (candidate) => candidate.path.join(" ") === "migration squash", + ); + expect(entry).toBeDefined(); + const failure = yield* runCommand(entry!, "none"); + + expect(failure).toMatchObject({ + _tag: "DbPasswordFlagsError", + message: + "migration squash targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.", + }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 837f619bc3..cab9462db2 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -13,13 +13,15 @@ import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { bold } from "./colors.ts"; import { promptYesNo } from "./prompt-yes-no.ts"; import { ipv6Suggestion, isIPv6ConnectivityError } from "./connect-errors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { resolveDbImage } from "./db-image.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; -import { loadProjectEnv, readDbToml, resolveDeclarativeDir } from "./db-config.toml-read.ts"; +import { loadProjectEnvValues, readDbToml, resolveDeclarativeDir } from "./db-config.toml-read.ts"; import type { DbConnType } from "./db-target-flags.ts"; import { makeDir } from "./make-dir.ts"; import { toPostgresURL } from "./postgres-url.ts"; @@ -40,7 +42,6 @@ import { resolveDeclarativeFromArgs, resolvePullDiffEngine, schemaPathsTransitionWarning, - shouldUsePgDelta, } from "./diff-engine.ts"; import { diffMigra } from "../commands/db/shared/migra.ts"; import { writePgDeltaMigrations } from "../commands/db/shared/pgdelta-migrations.write.ts"; @@ -62,7 +63,7 @@ import { PgDeltaEngine, type PgDeltaDatabaseEndpoint, } from "../commands/db/shared/pgdelta-engine.service.ts"; -import { type PgDeltaContext, isPgDeltaDebugEnabled, resolvePgDeltaProjectId } from "./pgdelta.ts"; +import { type PgDeltaContext, isPgDeltaDebugEnabled } from "./pgdelta.ts"; import { prepareShadowSource } from "../commands/db/shared/shadow-source.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; @@ -161,11 +162,12 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const dnsResolver = yield* DnsResolverFlag; const debug = yield* DebugFlag; const cliArgs = yield* CliArgs; + const configValues = yield* CliConfigValues; // `--yes` or `SUPABASE_YES`. The project `.env` is loaded before the migration // history prompt, so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms // the native initial-migra history repair too. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); // `EXPERIMENTAL` resolves from either the global `--experimental` flag or // `SUPABASE_EXPERIMENTAL`, reusing `resolveExperimentalWithProjectEnv`'s flag-over-env @@ -261,6 +263,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } + const resolvedConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(linkedRef), + }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeInfo = yield* RuntimeInfo; @@ -278,9 +284,6 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // So the shadow's own container spec reflects the matching `[remotes.]` // override, same as `toml` above. connType === "linked" ? linkedRef : undefined, - // `toml`'s remote-override-key tracking (same matched block), so a remote-set - // bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` env var here. - toml.remoteOverrideKeys, ), ); @@ -297,10 +300,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { - // Precedence: `SUPABASE_PROJECT_ID` env override, then config.toml's `project_id`, then - // the workdir basename fallback — with the matched `[remotes.]` block's own - // `project_id` suppressing the raw env argument on the linked path. - projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), + projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, cwd: cliSettings.workdir, denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, @@ -361,15 +361,13 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( engine: Option.getOrElse(flags.diffEngine, () => "migra"), pgDeltaDefault: (yield* currentStackBackend).kind === "stack" || - shouldUsePgDelta({ - configEnabled: toml.pgDelta.enabled, - usePgDeltaFlag: false, - }), + (yield* resolvedConfig.get(CliConfigKeys.experimental.pgdelta.enabled)).value, }); if (Option.getOrElse(flags.diffEngine, () => "pg-delta") === "migra") { yield* stackRejectNativeDockerDiffEngine("--diff-engine migra"); } const diffEngine = usePgDeltaDiff ? "pg-delta" : "migra"; + const pgDeltaDebug = yield* isPgDeltaDebugEnabled; // Connectivity check, run before dialing. return yield* Effect.scoped( @@ -397,7 +395,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), - debug: isPgDeltaDebugEnabled(), + debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); const exported = yield* withPoolerFallback(targetEndpoint, (target) => @@ -653,7 +651,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }, schema: diffSchema, formatOptions, - debug: isPgDeltaDebugEnabled(), + debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); } diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index 594a8eaa3a..e00eaff26d 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -1,6 +1,13 @@ import { Effect, FileSystem, Path } from "effect"; import { promptYesNo } from "./prompt-yes-no.ts"; +import { + SEED_CANCELLED_MESSAGE, + type DbSeedInput, + confirmSeedIntoMatchedRemote, + seedCancelledSuggestion, + seedConsentDryRunNote, +} from "./seed-remote-consent.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; import { Output } from "../shared/output/output.service.ts"; import { listLocalMigrations } from "./migration-list.ts"; @@ -71,6 +78,7 @@ export interface DbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; + readonly seed: DbSeedInput; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ @@ -105,6 +113,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn toml, yes, emitStructuredResult, + seed, } = input; const vaultSecrets = toml.vault; @@ -157,13 +166,13 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn let seeds: ReadonlyArray = []; if (includeSeed) { - if (!toml.seed.enabled) { + if (!seed.enabled) { yield* output.raw( `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, "stderr", ); } else { - seeds = yield* getPendingSeeds(session, fs, path, toml.seed.sqlPaths, workdir); + seeds = yield* getPendingSeeds(session, fs, path, seed.sqlPaths, workdir); } } @@ -214,8 +223,25 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn if (seeds.length > 0) { yield* output.raw("Would seed these files:\n", "stderr"); yield* output.raw(confirmSeedAll(seeds), "stderr"); + if (seed.consent !== undefined) yield* seedConsentDryRunNote(seed.consent, yes); } } else { + const seedConsented = seeds.length > 0 && seed.consent !== undefined; + if (seeds.length > 0 && seed.consent !== undefined) { + const consented = yield* confirmSeedIntoMatchedRemote({ + command: "push", + target: seed.consent, + files: seeds.map((s) => s.path), + yes, + }); + if (!consented) { + return yield* new DbPushCancelledError({ + message: SEED_CANCELLED_MESSAGE, + suggestion: seedCancelledSuggestion("push"), + }); + } + } + if (globals.length > 0) { const ok = yield* promptYesNo( output, @@ -254,12 +280,14 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (seeds.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, - true, - ); + const ok = + seedConsented || + (yield* promptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + )); if (!ok) { return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 8622d8e393..007426b9a4 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -1,38 +1,31 @@ /** - * Pure flag-presence helpers for the `--db-url / --linked / --local` target selection shared by + * Flag-presence helpers for the `--db-url / --linked / --local` target selection shared by * `db lint`, `db advisors`, and `test db`. * - * Effect CLI's parsed flags carry no "was this explicitly set" bit, so this re-derives it from - * raw `process.argv`. The mutually-exclusive-group error's "were all set" list is alphabetically - * sorted regardless of each command's own flag registration order, which `setFlags` always - * returns. A value-consuming flag given in space form (`--flag value`) has its value token - * skipped during the scan, so e.g. `--schema --linked` does not misdetect `--linked` as changed. + * Parsed flags carry no "was this explicitly set" bit, so presence is re-derived from raw argv, + * skipping the value token of space-form value flags (`--schema --linked` does not set `--linked`). */ +import { Effect, Option } from "effect"; + import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; +import { DbPasswordFlagsError } from "./db-config.errors.ts"; export type DbConnType = "db-url" | "linked" | "local"; export interface DbTargetSelection { - /** Alphabetically-sorted list of explicitly-set selector flags ("db-url", "linked", "local"). */ + /** Explicitly-set selector flags, alphabetically sorted. */ readonly setFlags: ReadonlyArray; - /** - * Changed-first selection: db-url > local > linked (if changed) > undefined (→ local - * default). `undefined` means no selector was explicitly set; callers default to "local". - */ + /** db-url > local > linked; `undefined` when no selector was set (callers default to local). */ readonly connType: DbConnType | undefined; } /** - * Long-form flags that consume the next token as their value in space-separated form - * (`--flag value`), so the target-selector scan skips it. Also consulted by - * `extractChangedFlagNames` across every command's argv, so this lists every value-consuming - * flag declared anywhere under `commands/`; a missing entry can misdetect a flag's value token - * as a global flag's long name. `db-target-flags.unit.test.ts` enforces coverage for - * directly-declared flags; flags built through a name-parameterized helper are listed by hand. + * Long flags that consume the next token in space form. Also used by `extractChangedFlagNames` + * across every command's argv, so it lists every value flag under `commands/`; the unit test + * enforces coverage for directly-declared flags, helper-built ones are listed by hand. */ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ - // db-family command flags "db-url", "password", // db push/pull/dump/remote (short -p) "sql-paths", @@ -40,15 +33,10 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "level", "fail-on", "type", - // migration/db credential flag; consumes the next token as its value. - "password", - // inspect report flag "output-dir", - // storage cp command flags (Flag.String / Flag.Int) "cache-control", "content-type", "jobs", - // global flags (Flag.String / Flag.Literals) "output", "output-format", "profile", @@ -56,11 +44,8 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "network-id", "dns-resolver", "agent", - // `--log-level` is not listed: an argv giving it a flag-shaped value fails the real parse - // before any scanner here runs, so nothing can mis-consume around it. `--completions` prints - // and exits before any handler runs, so these scans never see it either. - // Every other value-consuming flag declared directly across commands/ (see the doc comment - // above). + // `--log-level` and `--completions` are unlisted: the real parse or an early exit precedes + // every scan here. "add-domains", "algorithm", "attribute-mapping-file", @@ -118,10 +103,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "token", "valid-for", "version", - // Declared through a name-parameterized helper, invisible to the static scan (see the doc - // comment above): `issue.command.ts`'s `issueOptionalTextFlag`. (The `stringSliceFlag`-built - // names — domains, add-domains, remove-domains, config, exclude, override-name, db-unban-ip, - // db-allow-cidr — are already listed in the sections above.) + // Built by `issueOptionalTextFlag`, invisible to the static scan. "additional-context", "area", "command", @@ -136,7 +118,6 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "link", "issue-type", "improvement", - // experimental stack flags "capability", "service", "since", @@ -145,10 +126,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "preparation", ]); -/** - * Short flags (without `-` prefix) that consume the next token as their value. - * Only single-character short flags need to be listed here. - */ +/** Single-character short flags (no `-` prefix) that consume the next token. */ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "s", // --schema / -s "o", // --output / -o @@ -160,13 +138,8 @@ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ ]); /** - * Detects which of `--linked` / `--local` were explicitly set on the command line, for `seed - * buckets` and `storage ls/cp/mv/rm`'s mutually-exclusive-flag error message. - * - * Effect CLI's parsed flags carry no "was this set" bit, so this re-derives it from raw argv, - * skipping value tokens of space-separated value-consuming flags to avoid false positives. The - * negation form (`--no-linked`/`--no-local`) counts as changed. Returned alphabetically sorted - * (`["linked", "local"]`) so the rendered conflict string matches exactly. + * Which of `--linked` / `--local` were explicitly set (including `--no-` forms), sorted, for the + * mutually-exclusive-flag error of `seed buckets` and `storage ls/cp/mv/rm`. */ export function changedLinkedLocalFlags(args: ReadonlyArray): ReadonlyArray { let linked = false; @@ -209,20 +182,7 @@ export function changedLinkedLocalFlags(args: ReadonlyArray): ReadonlyAr return setFlags; } -/** - * Resolves the DB target selection from raw CLI args with a single left-to-right pass, skipping - * value tokens that follow space-separated value-consuming flags to avoid false-positive - * detection. - * - * `setFlags` is built in the fixed alphabetical order `["db-url", "linked", "local"]` so the - * rendered conflict string (`[db-url linked]`, `[linked local]`, …) matches exactly. - * - * `connType` follows Changed-first precedence: - * 1. `--db-url` if changed → "db-url" - * 2. `--local` if changed → "local" - * 3. `--linked` if changed → "linked" - * 4. none changed → `undefined` (callers default to "local") - */ +/** Resolves the DB target selection from raw args; `setFlags` is sorted so conflict text is stable. */ export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelection { let dbUrlChanged = false; let linkedChanged = false; @@ -230,8 +190,7 @@ export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelec let skipNext = false; for (const token of args) { - // A value-consuming flag consumes the next token as its value even when that token is "--". - // Only a "--" that is not a pending value acts as the end-of-options sentinel. + // A pending value is consumed even when it is "--". if (skipNext) { skipNext = false; continue; @@ -244,10 +203,8 @@ export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelec const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); const isBare = eqIdx === -1; - // Check target selectors. if (name === "db-url") { dbUrlChanged = true; - // --db-url is a string flag: in space form the next token is the value. if (isBare) skipNext = true; continue; } @@ -260,18 +217,15 @@ export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelec continue; } - // Non-target long flag: skip its value token if value-consuming and bare. if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) { skipNext = true; } continue; } - // Short flags: `-s`, `-o`, etc. if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { const shortName = token.charAt(1); - // `-s` bare (length === 2): next token is the value. - // `-svalue` (length > 2): value is attached, no skip needed. + // `-svalue` carries its value attached; only bare `-s` skips the next token. if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(shortName)) { skipNext = true; } @@ -294,3 +248,31 @@ export function resolveDbTargetFlags(args: ReadonlyArray): DbTargetSelec return { setFlags, connType }; } + +/** + * `--password` only authenticates against a linked project, so it fails when paired with a target + * that carries its own credentials (`--db-url`) or reads them from config (`--local`, also the + * default when no selector is set). `localByDefaultFor` names the command whose unset selector + * fell back to local, so the message can point at `--linked` instead of a `--local` the user never typed. + */ +export const rejectPasswordWithDirectTarget = ( + connType: DbConnType | undefined, + password: Option.Option | undefined, + options: { readonly localByDefaultFor?: string } = {}, +): Effect.Effect => { + if ( + password === undefined || + Option.isNone(Option.filter(password, (value) => value.length > 0)) || + connType === "linked" + ) { + return Effect.void; + } + const target = connType ?? "local"; + const message = + target === "db-url" + ? "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres" + : options.localByDefaultFor !== undefined + ? `${options.localByDefaultFor} targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.` + : "--password can't be used with --local. The local database uses [db].password from supabase/config.toml."; + return Effect.fail(new DbPasswordFlagsError({ message })); +}; diff --git a/apps/cli/src/command-internal/db-target-flags.unit.test.ts b/apps/cli/src/command-internal/db-target-flags.unit.test.ts index 73f63ad310..aebc2584e2 100644 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ b/apps/cli/src/command-internal/db-target-flags.unit.test.ts @@ -1,9 +1,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, Exit, FileSystem, Option, Path } from "effect"; import { fileURLToPath } from "node:url"; +import { CLI_CONFIG_FLAGS } from "../config/cli-config-key-annotations.ts"; +import { cliConfigRegistry } from "../config/cli-config-keys.ts"; import { changedLinkedLocalFlags, + rejectPasswordWithDirectTarget, resolveDbTargetFlags, VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, @@ -178,9 +181,16 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness // `VALUE_CONSUMING_LONG_FLAGS` in `db-target-flags.ts`. This scan is static-source-based // rather than importing every command module, so it can only see flag names declared as a // literal string; a name passed through a helper function is registered by hand instead. + // Config-bound flags (`CliConfigKeys..flag({ name })`) take their value-consuming kind + // from the key's codec in the registry. const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); const INDIRECT_NAME_FILES = new Set(["issue.command.ts"]); - const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; + const VALUE_FLAG_KINDS = ["String", "Int", "Finite", "Literals", "ChoiceWithValue"]; + const configFlagPath = new Map( + Object.entries(CLI_CONFIG_FLAGS).flatMap(([keyPath, { names }]) => + names.map((name) => [name, keyPath] as const), + ), + ); const walk = Effect.fnUntraced(function* (dir: string) { const fs = yield* FileSystem.FileSystem; @@ -197,35 +207,66 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness readonly alias: string | undefined; } + const consumesValue = (kind: string | undefined, name: string): boolean => { + if (kind !== undefined) return VALUE_FLAG_KINDS.includes(kind); + const keyPath = configFlagPath.get(name); + const codecKind = + keyPath === undefined ? undefined : cliConfigRegistry.keyAt(keyPath)?.codec.kind; + return codecKind !== "bool"; + }; + function extractDeclaredFlags(filePath: string, source: string): Array { - const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; + const callRegex = /\bFlag\.([A-Z]\w*)\(|\.flag\(\{/g; const calls = Array.from(source.matchAll(callRegex), (match) => ({ index: match.index, - kind: match[1]!, + kind: match[1], })); const declared: Array = []; for (let i = 0; i < calls.length; i++) { const current = calls[i]!; - if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; - // Name declared as a literal string (e.g. `Flag.String("schema")`); a name passed as an - // identifier doesn't match and is silently skipped — see INDIRECT_NAME_FILES above. + // Name declared as a literal string (e.g. `Flag.String("schema")` or + // `.flag({ name: "sql-paths" })`); a name passed as an identifier doesn't match and is + // silently skipped — see INDIRECT_NAME_FILES above. const remainder = source.slice(current.index); - const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); - if (!nameMatch) continue; + const nameMatch = remainder.match(/^(?:Flag\.\w+\(|\.flag\(\{\s*name:)\s*"([a-zA-Z0-9-]+)"/); + if (!nameMatch || !consumesValue(current.kind, nameMatch[1]!)) continue; - // The alias, if any, is somewhere in the `.pipe(...)` chain between + // The alias, if any, is somewhere in the `.pipe(...)` chain or options object between // this flag declaration and the next one. const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; const window = source.slice(current.index, windowEnd); - const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); - - declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); + const aliasMatch = window.match( + /withAlias\(\s*"([a-zA-Z0-9])"\s*\)|\balias:\s*"([a-zA-Z0-9])"/, + ); + + declared.push({ + file: filePath, + name: nameMatch[1]!, + alias: aliasMatch?.[1] ?? aliasMatch?.[2], + }); } return declared; } + it.effect("finds both directly-declared and config-bound value flags in the scan", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const names = new Set(); + for (const filePath of yield* walk(commandsDir)) { + for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { + names.add(flag.name); + } + } + + expect(names).toContain("schema"); + expect(names).toContain("sql-paths"); + expect(names).toContain("password"); + expect(names).not.toContain("no-seed"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect( "registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", () => @@ -334,3 +375,22 @@ describe("changedLinkedLocalFlags", () => { expect(changedLinkedLocalFlags(["storage", "rm", "ss:///b/x", "--local"])).toEqual(["local"]); }); }); + +describe("rejectPasswordWithDirectTarget", () => { + const run = (connType: "local" | "db-url", password: string) => + Effect.runSyncExit(rejectPasswordWithDirectTarget(connType, Option.some(password))); + + it.each(["local", "db-url"] as const)( + "treats an empty --password as absent with %s", + (connType) => { + expect(Exit.isSuccess(run(connType, ""))).toBe(true); + }, + ); + + it.each(["local", "db-url"] as const)( + "still rejects a non-empty --password with %s", + (connType) => { + expect(Exit.isFailure(run(connType, "x"))).toBe(true); + }, + ); +}); diff --git a/apps/cli/src/command-internal/debug-logger.layer.ts b/apps/cli/src/command-internal/debug-logger.layer.ts index 6dbfc7acda..a2de6fc90c 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.ts @@ -1,7 +1,7 @@ import { DateTime, Effect, Layer } from "effect"; import { DebugFlag } from "./global-flags.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; const pad = (n: number): string => String(n).padStart(2, "0"); diff --git a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts index 2de4097ae7..98592d656c 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts @@ -6,7 +6,7 @@ import { vi } from "vitest"; import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { DebugFlag } from "./global-flags.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; function makeLayer(debug: boolean) { return debugLoggerLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, debug))); diff --git a/apps/cli/src/command-internal/diff-engine.ts b/apps/cli/src/command-internal/diff-engine.ts index d6bc7cf89b..8246995d8e 100644 --- a/apps/cli/src/command-internal/diff-engine.ts +++ b/apps/cli/src/command-internal/diff-engine.ts @@ -4,18 +4,6 @@ export const schemaPathsTransitionWarning = "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. `supabase db schema declarative sync` reads declarative_schema_path (default supabase/schemas), not schema_paths: move those files there or point declarative_schema_path at them. Only migra (--use-migra, --diff-engine migra, or [experimental.pgdelta] enabled = false) still reads schema_paths, and only for local targets.\n"; -/** - * Whether pg-delta is the active engine: `[experimental.pgdelta].enabled` (default true) or the - * command's `--use-pg-delta` flag. `SUPABASE_EXPERIMENTAL_PG_DELTA` is not an input, so a stale - * opt-in cannot defeat an explicit `enabled = false` rollback. - */ -export function shouldUsePgDelta(inputs: { - readonly configEnabled: boolean; - readonly usePgDeltaFlag: boolean; -}): boolean { - return inputs.configEnabled || inputs.usePgDeltaFlag; -} - /** * Reports whether `db diff` should run in pg-delta mode. A true `--use-migra` or `--use-pgadmin` * is an authoritative rollback that clears pg-delta mode; `--use-migra=false` keeps the default. diff --git a/apps/cli/src/command-internal/diff-engine.unit.test.ts b/apps/cli/src/command-internal/diff-engine.unit.test.ts index 8790f24044..de7c1e36a5 100644 --- a/apps/cli/src/command-internal/diff-engine.unit.test.ts +++ b/apps/cli/src/command-internal/diff-engine.unit.test.ts @@ -5,17 +5,8 @@ import { resolveDeclarativeFromArgs, resolveDiffEngine, resolvePullDiffEngine, - shouldUsePgDelta, } from "./diff-engine.ts"; -describe("shouldUsePgDelta", () => { - it("follows the config and lets --use-pg-delta override an explicit rollback", () => { - expect(shouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false })).toBe(false); - expect(shouldUsePgDelta({ configEnabled: true, usePgDeltaFlag: false })).toBe(true); - expect(shouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: true })).toBe(true); - }); -}); - describe("resolveDiffEngine", () => { const base = { useMigra: false, diff --git a/apps/cli/src/command-internal/docker-ids.ts b/apps/cli/src/command-internal/docker-ids.ts index d13c9ffdf9..389576db84 100644 --- a/apps/cli/src/command-internal/docker-ids.ts +++ b/apps/cli/src/command-internal/docker-ids.ts @@ -4,45 +4,7 @@ * the local stack is running. */ -import { basename } from "node:path"; - -/** - * Resolves the local project id used to derive Docker resource names. - * - * Precedence: `SUPABASE_PROJECT_ID` env var, then config.toml's `project_id`, then - * `--project-ref` (when the command accepts one), then the working directory's basename. - */ -export function resolveLocalProjectId( - envProjectId: string | undefined, - tomlProjectId: string | undefined, - workdir: string, - projectRefDefault?: string, -): string { - if (envProjectId !== undefined && envProjectId.length > 0) return envProjectId; - if (tomlProjectId !== undefined && tomlProjectId.length > 0) return tomlProjectId; - if (projectRefDefault !== undefined && projectRefDefault.length > 0) return projectRefDefault; - return basename(workdir); -} - -const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; -const MAX_PROJECT_ID_LENGTH = 40; - -function truncateText(text: string, maxLength: number) { - return text.length > maxLength ? text.slice(0, maxLength) : text; -} - -/** - * Sanitizes a project id for use in container/network names: replaces invalid character runs - * with `_`, strips leading `_.-`, and caps the result at 40 characters. - * - * Exported separately from {@link serviceContainerName} because a Docker label filter built - * from a project id must be sanitized the same way, or a `project_id` like `"my app"` won't - * match the label `start` wrote (see {@link cliProjectFilterValue}). - */ -export function sanitizeProjectId(src: string) { - const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); - return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); -} +import { sanitizeProjectId } from "../shared/config/project-id.ts"; /** `supabase__` — the naming scheme for local Docker resources. */ export function serviceContainerName(suffix: string, projectId: string): string { diff --git a/apps/cli/src/command-internal/docker-ids.unit.test.ts b/apps/cli/src/command-internal/docker-ids.unit.test.ts index 7cb1e22005..f4c6a2085e 100644 --- a/apps/cli/src/command-internal/docker-ids.unit.test.ts +++ b/apps/cli/src/command-internal/docker-ids.unit.test.ts @@ -1,39 +1,18 @@ -import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect } from "effect"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { CLI_PROJECT_LABEL, cliProjectFilterValue, - resolveLocalProjectId, - sanitizeProjectId, serviceContainerIds, - localDbContainerId, localNetworkId, } from "./docker-ids.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; import { resolveDockerNetworkMode } from "../shared/functions/functions-docker.ts"; import { supabaseEnvStringWithProjectFallback } from "./supabase-env.ts"; -describe("resolveLocalProjectId", () => { - it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { - expect(resolveLocalProjectId("env-id", "toml-id", "/work/proj")).toBe("env-id"); - }); - - it("falls back to config.toml project_id when the env var is unset/empty", () => { - expect(resolveLocalProjectId(undefined, "toml-id", "/work/proj")).toBe("toml-id"); - expect(resolveLocalProjectId("", "toml-id", "/work/proj")).toBe("toml-id"); - }); - - it("falls back to the workdir basename when both env and config.toml are absent", () => { - expect(resolveLocalProjectId(undefined, undefined, "/work/my-app")).toBe("my-app"); - expect(resolveLocalProjectId(undefined, "", "/work/my-app")).toBe("my-app"); - }); - - it("feeds the resolved id into the local db container name", () => { - const id = resolveLocalProjectId("env-id", undefined, "/work/proj"); - expect(localDbContainerId(id)).toBe("supabase_db_env-id"); - }); -}); - describe("serviceContainerIds", () => { it("returns the 13 service container ids in order", () => { expect(serviceContainerIds("my-app")).toEqual([ @@ -78,89 +57,49 @@ describe("cliProjectFilterValue", () => { describe("resolveDockerNetworkMode composed with supabaseEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; - const withShellEnv = (env: Readonly>) => - Effect.provide( - ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), + function resolve( + flagValue: string | undefined, + projectEnv: Record, + shell?: string, + ) { + return Effect.runSync( + withConfigEnv( + shell === undefined ? {} : { [KEY]: shell }, + Effect.map(supabaseEnvStringWithProjectFallback(KEY, projectEnv), (envNetworkId) => + resolveDockerNetworkMode({ explicit: flagValue, envNetworkId, projectId: "my-app" }), + ), + ), ); - - function resolve(flagValue: string | undefined, projectEnv: Record) { - return Effect.gen(function* () { - return resolveDockerNetworkMode({ - explicit: flagValue, - envOverride: yield* supabaseEnvStringWithProjectFallback(KEY, projectEnv), - projectId: "my-app", - }); - }); } - it.effect("prefers an explicit --network-id flag over everything else", () => - Effect.gen(function* () { - expect( - yield* resolve("flag-network", { [KEY]: "toml-network" }).pipe( - withShellEnv({ [KEY]: "env-network" }), - ), - ).toBe("flag-network"); - }), - ); - - it.effect("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => - Effect.gen(function* () { - expect(yield* resolve(undefined, {}).pipe(withShellEnv({ [KEY]: "shell-network" }))).toBe( - "shell-network", - ); - }), - ); - - it.effect( - "falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", - () => - Effect.gen(function* () { - expect(yield* resolve(undefined, { [KEY]: "project-network" }).pipe(withShellEnv({}))).toBe( - "project-network", - ); - }), - ); - - it.effect("prefers the shell value over the project .env value (presence wins)", () => - Effect.gen(function* () { - expect( - yield* resolve(undefined, { [KEY]: "project-network" }).pipe( - withShellEnv({ [KEY]: "shell-network" }), - ), - ).toBe("shell-network"); - }), - ); - - it.effect( - "falls back to the generated network name when the flag and env are all absent/empty", - () => - Effect.gen(function* () { - expect(yield* resolve(undefined, {}).pipe(withShellEnv({}))).toBe(localNetworkId("my-app")); - expect(yield* resolve("", {}).pipe(withShellEnv({}))).toBe(localNetworkId("my-app")); - }), - ); - - it.effect( - "an explicit-but-empty --network-id= skips the env var entirely (an explicit flag beats the env var)", - () => - Effect.gen(function* () { - expect( - yield* resolve("", { [KEY]: "project-network" }).pipe( - withShellEnv({ [KEY]: "env-network" }), - ), - ).toBe(localNetworkId("my-app")); - }), - ); - - it.effect( - "treats an empty shell value as present (blocks the project value) and falls to generated", - () => - Effect.gen(function* () { - expect( - yield* resolve(undefined, { [KEY]: "project-network" }).pipe(withShellEnv({ [KEY]: "" })), - ).toBe(localNetworkId("my-app")); - }), - ); + it("prefers an explicit --network-id flag over everything else", () => { + expect(resolve("flag-network", { [KEY]: "toml-network" }, "env-network")).toBe("flag-network"); + }); + + it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { + expect(resolve(undefined, {}, "shell-network")).toBe("shell-network"); + }); + + it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); + }); + + it("prefers the shell value over the project .env value", () => { + expect(resolve(undefined, { [KEY]: "project-network" }, "shell-network")).toBe("shell-network"); + }); + + it("falls back to the generated network name when the flag and env are all absent/empty", () => { + expect(resolve(undefined, {})).toBe(localNetworkId("my-app")); + expect(resolve("", {})).toBe(localNetworkId("my-app")); + }); + + it("an explicit-but-empty --network-id= skips the env var entirely", () => { + expect(resolve("", { [KEY]: "project-network" }, "env-network")).toBe(localNetworkId("my-app")); + }); + + it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { + expect(resolve(undefined, { [KEY]: "project-network" }, "")).toBe(localNetworkId("my-app")); + }); }); describe("sanitizeProjectId", () => { diff --git a/apps/cli/src/command-internal/docker-image-resolve.ts b/apps/cli/src/command-internal/docker-image-resolve.ts index d9ddd9ef6f..8d159374e1 100644 --- a/apps/cli/src/command-internal/docker-image-resolve.ts +++ b/apps/cli/src/command-internal/docker-image-resolve.ts @@ -132,14 +132,14 @@ export function makeDockerImageResolver( Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { stdoutChunks.push(chunk); - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { stderrChunks.push(chunk); - globalThis.process.stderr.write(chunk); + process.stderr.write(chunk); if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), @@ -246,9 +246,9 @@ export function makeDockerImageResolver( // one, so the two don't glue together. yield* Effect.sync(() => { if (!lastPullEndedWithNewline) { - globalThis.process.stderr.write("\n"); + process.stderr.write("\n"); } - globalThis.process.stderr.write(`Retrying after ${delay / 1000}s: ${candidate}\n`); + process.stderr.write(`Retrying after ${delay / 1000}s: ${candidate}\n`); }); yield* Effect.sleep(`${delay} millis`); } diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index f9a3e4cced..c4c98e6ad0 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -88,7 +88,7 @@ function parsePrunedNames(stdout: string): ReadonlyArray { const reportPruned = (debug: boolean, label: string, stdout: string) => Effect.sync(() => { if (!debug) return; - globalThis.process.stderr.write(`${label} [${parsePrunedNames(stdout).join(" ")}]\n`); + process.stderr.write(`${label} [${parsePrunedNames(stdout).join(" ")}]\n`); }); /** Every failure {@link dockerRemoveAll} can produce. */ diff --git a/apps/cli/src/command-internal/docker-run.layer.ts b/apps/cli/src/command-internal/docker-run.layer.ts index 535238b569..fb3edc8f1c 100644 --- a/apps/cli/src/command-internal/docker-run.layer.ts +++ b/apps/cli/src/command-internal/docker-run.layer.ts @@ -89,7 +89,7 @@ export const dockerRunLayer: Layer.Layer()), BunServices.layer, + cliConfigValuesTestLayer, ), ), ); diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.ts index 3e700bf807..36777a247e 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.ts @@ -4,6 +4,7 @@ import * as Net from "node:net"; import { DebugFlag, NetworkIdFlag } from "./global-flags.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; import { readDbToml } from "./db-config.toml-read.ts"; import { getRegistryImageUrl } from "./docker-registry.ts"; import { DockerRun } from "./docker-run.service.ts"; @@ -49,6 +50,7 @@ export const edgeRuntimeScriptLayer = Layer.effect( const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const runtimeInfo = yield* RuntimeInfo; + const configValues = yield* CliConfigValues; // The pg-delta container needs `host.docker.internal:host-gateway` on Linux only, so a // `host.docker.internal` local DB host (from SUPABASE_SERVICES_HOSTNAME) resolves inside the // container on Linux/dev-container; Docker Desktop already provides this on macOS/Windows. @@ -79,6 +81,7 @@ export const edgeRuntimeScriptLayer = Layer.effect( const denoVersion = opts.denoVersion ?? (yield* readDbToml(fs, path, workdir).pipe( + Effect.provideService(CliConfigValues, configValues), Effect.mapError((error) => new EdgeRuntimeScriptError({ message: error.message })), )).denoVersion; const registryImage = yield* getRegistryImageUrl( diff --git a/apps/cli/src/command-internal/experimental-feature.ts b/apps/cli/src/command-internal/experimental-feature.ts index 1eba2f160d..3aead29bf7 100644 --- a/apps/cli/src/command-internal/experimental-feature.ts +++ b/apps/cli/src/command-internal/experimental-feature.ts @@ -1,6 +1,8 @@ import { CliConfigSchema, findCliProjectPaths } from "@supabase/config/effect"; -import { Config, ConfigProvider, Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Config, Data, Effect, FileSystem, Option, Path, Result, Schema } from "effect"; import * as SmolToml from "smol-toml"; +import { pickCliConfigKey } from "../config/cli-config-key.ts"; +import { CliConfigKeys } from "../config/cli-config-keys.ts"; import { resolveWorkdir } from "../config/command-settings.layer.ts"; import { rootFlagTokens } from "../shared/cli/run.ts"; import { @@ -36,12 +38,17 @@ const firstExplicitLongFlagValue = ( const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const readEnv = (name: string) => + Config.option(Config.String(name)).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.map(Option.getOrUndefined), + ); + /** Reads one experimental feature, treating unavailable or invalid configuration as unset. */ export const readExperimentalFeatureConfig = (input: { readonly feature: keyof typeof featureSchemas; readonly args: ReadonlyArray; readonly cwd: string; - readonly env: Readonly>; }): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -49,14 +56,11 @@ export const readExperimentalFeatureConfig = (input: { const explicitWorkdir = firstExplicitLongFlagValue(input.args, "workdir"); const resolvedWorkdir = yield* resolveWorkdir( explicitWorkdir === undefined ? Option.none() : Option.some(explicitWorkdir), - Option.fromNullishOr(input.env["SUPABASE_WORKDIR"]), + Option.fromNullishOr(yield* readEnv("SUPABASE_WORKDIR")), input.cwd, - (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), path, ); - const project = yield* findCliProjectPaths(resolvedWorkdir.workdir, { - search: !resolvedWorkdir.explicit, - }); + const project = yield* findCliProjectPaths(resolvedWorkdir.workdir, { search: false }); if (project === null) return undefined; const content = yield* fs.readFileString(project.configPath); const document = project.configPath.endsWith(".json") @@ -71,38 +75,41 @@ export const readExperimentalFeatureConfig = (input: { return decoded.experimental?.[input.feature]; }).pipe(Effect.orElseSucceed(() => undefined)); +const featureKeys = { + stack: CliConfigKeys.experimental.stack, + compute: CliConfigKeys.experimental.compute, +} as const; + /** - * Env record for one experimental feature, read from ConfigProvider. + * Resolves one experimental boolean: a shell boolean override, else the project + * config. Remotes and project `.env*` files do not apply. */ -export const experimentalFeatureEnv = ( - feature: string, -): Effect.Effect>> => - Effect.gen(function* () { - const envName = `SUPABASE_EXPERIMENTAL_${feature.toUpperCase()}`; - const provider = yield* ConfigProvider.ConfigProvider; - const override = yield* Config.option(Config.String(envName)) - .parse(provider) - .pipe(Effect.orElseSucceed(() => Option.none())); - return { [envName]: Option.getOrUndefined(override) }; - }); - -/** Resolves one experimental boolean from its environment override and config fallback. */ export const resolveExperimentalFeature = (input: { - readonly feature: string; + readonly feature: keyof typeof featureKeys; readonly configValue: Effect.Effect; - readonly env: Readonly>; -}): Effect.Effect => { - const envName = `SUPABASE_EXPERIMENTAL_${input.feature.toUpperCase()}`; - const override = input.env[envName]; - if (override === undefined || override === "") { - return input.configValue.pipe(Effect.map((value) => value === true)); - } - if (override === "1") return Effect.succeed(true); - if (override === "0") return Effect.succeed(false); - return Effect.fail( - new ExperimentalFeatureFlagError({ - envName, - message: `${envName} must be 0 or 1 when set`, - }), - ); -}; +}): Effect.Effect => + Effect.gen(function* () { + const key = featureKeys[input.feature]; + const envName = `SUPABASE_EXPERIMENTAL_${input.feature.toUpperCase()}`; + const path = yield* Path.Path; + const shell = yield* readEnv(envName); + const configValue = shell === undefined || shell === "" ? yield* input.configValue : undefined; + const picked = pickCliConfigKey(key, { + flags: () => undefined, + shell: (name) => (name === envName ? shell : undefined), + projectEnv: () => undefined, + config: (segments) => + segments.join(".") === key.path && configValue !== undefined + ? { value: configValue, origin: { path: segments, source: "local" } } + : undefined, + dotenvPrivateKeys: [], + context: { workdir: "", projectRef: Option.none(), path, configAt: () => undefined }, + }); + if (Result.isFailure(picked)) { + return yield* new ExperimentalFeatureFlagError({ + envName, + message: picked.failure.message, + }); + } + return Option.getOrElse(picked.success.value, () => false); + }); diff --git a/apps/cli/src/command-internal/experimental-feature.unit.test.ts b/apps/cli/src/command-internal/experimental-feature.unit.test.ts new file mode 100644 index 0000000000..a782464669 --- /dev/null +++ b/apps/cli/src/command-internal/experimental-feature.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path } from "effect"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { + ExperimentalFeatureFlagError, + resolveExperimentalFeature, +} from "./experimental-feature.ts"; + +const resolveStack = (value: string) => + withConfigEnv( + { SUPABASE_EXPERIMENTAL_STACK: value }, + resolveExperimentalFeature({ feature: "stack", configValue: Effect.succeed(undefined) }), + ).pipe(Effect.provide(Path.layer)); + +describe("resolveExperimentalFeature boolean spellings", () => { + it.effect.each(["1", "t", "T", "TRUE", "true", "True"])("enables on %s", (value) => + Effect.gen(function* () { + expect(yield* resolveStack(value)).toBe(true); + }), + ); + + it.effect.each(["tRuE", "tRue", "yes"])("rejects %s", (value) => + Effect.gen(function* () { + const error = yield* resolveStack(value).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ExperimentalFeatureFlagError); + expect(error.envName).toBe("SUPABASE_EXPERIMENTAL_STACK"); + }), + ); +}); diff --git a/apps/cli/src/command-internal/experimental-gate.unit.test.ts b/apps/cli/src/command-internal/experimental-gate.unit.test.ts index f430f5bf18..34ca11232c 100644 --- a/apps/cli/src/command-internal/experimental-gate.unit.test.ts +++ b/apps/cli/src/command-internal/experimental-gate.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { ExperimentalFlag } from "./global-flags.ts"; import { ExperimentalRequiredError, requireExperimental } from "./experimental-gate.ts"; @@ -8,8 +9,6 @@ import { ExperimentalRequiredError, requireExperimental } from "./experimental-g const ENV = "SUPABASE_EXPERIMENTAL"; const withFlag = (value: boolean, args: ReadonlyArray = []) => Layer.mergeAll(Layer.succeed(ExperimentalFlag, value), Layer.succeed(CliArgs, { args })); -const withShellEnv = (env: Readonly>) => - ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })); describe("requireExperimental", () => { it.effect("passes when --experimental is set", () => @@ -18,9 +17,10 @@ describe("requireExperimental", () => { it.effect("fails with the gate message when neither flag nor env is set", () => Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide(Layer.merge(withFlag(false), withShellEnv({}))), - Effect.flip, + const error = yield* withEnvVar( + ENV, + undefined, + requireExperimental.pipe(Effect.provide(withFlag(false)), Effect.flip), ); expect(error).toBeInstanceOf(ExperimentalRequiredError); expect(error.message).toBe("must set the --experimental flag to run this command"); @@ -29,9 +29,10 @@ describe("requireExperimental", () => { it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag", () => Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide(Layer.merge(withFlag(false), withShellEnv({ [ENV]: "1" }))), - Effect.exit, + const exit = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe(Effect.provide(withFlag(false)), Effect.exit), ); expect(Exit.isSuccess(exit)).toBe(true); }), @@ -39,11 +40,13 @@ describe("requireExperimental", () => { it.effect("fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit", () => Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide( - Layer.merge(withFlag(false, ["--experimental=false"]), withShellEnv({ [ENV]: "1" })), + const error = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe( + Effect.provide(withFlag(false, ["--experimental=false"])), + Effect.flip, ), - Effect.flip, ); expect(error).toBeInstanceOf(ExperimentalRequiredError); }), @@ -53,14 +56,13 @@ describe("requireExperimental", () => { "passes with SUPABASE_EXPERIMENTAL=1 when --experimental=false is a positional operand after --", () => Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide( - Layer.merge( - withFlag(false, ["--", "--experimental=false"]), - withShellEnv({ [ENV]: "1" }), - ), + const exit = yield* withEnvVar( + ENV, + "1", + requireExperimental.pipe( + Effect.provide(withFlag(false, ["--", "--experimental=false"])), + Effect.exit, ), - Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); }), diff --git a/apps/cli/src/command-internal/functions-local-config.ts b/apps/cli/src/command-internal/functions-local-config.ts index 50ff4991eb..e70704c837 100644 --- a/apps/cli/src/command-internal/functions-local-config.ts +++ b/apps/cli/src/command-internal/functions-local-config.ts @@ -27,28 +27,19 @@ export const functionsLocalConfigLoader: FunctionsLocalConfigLoader = { context.config, context.hostname, projectRoot, - context.projectEnvValues, - context.loaded?.document, - // No `[remotes.]` override-tier gating (empty set, the default): the remote - // block already merged over the base config at file level via - // `loadLocalProjectContext`'s `projectRef` threading above. Known narrow divergence: - // an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` still beats a matched remote - // block's own `deno_version` here, since computing the override keys needs - // `db-config.toml-read.ts`'s remote-resolution pipeline, which this path doesn't run. - undefined, - projectRef, + context.resolvedConfig.loaded.document, ), catch: toError, }); return { - loaded: context.loaded, + loaded: { config: context.config, document: context.resolvedConfig.loaded.document }, + resolvedConfig: context.resolvedConfig, + configPath: context.resolvedConfig.hasConfigFile + ? context.resolvedConfig.loaded.path + : undefined, projectEnvValues: context.projectEnvValues, - // `context.projectId`, not `validated.projectId`: the context's id is the one built for - // Docker naming/labels — sanitized, `--project-ref` defaulted, and - // `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` block matched (see - // `local-project-context.ts`'s gate). `validated.projectId` exists only to feed - // `validateResolvedConfig`'s emptiness check and skips that gate — see its own doc - // comment. + // `context.projectId` is the id built for Docker naming/labels; `validated.projectId` + // exists only to feed `validateResolvedConfig`'s emptiness check. projectId: context.projectId, denoVersion: validated.edgeRuntimeDenoVersion, }; diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index e4bc1ae9d1..d737d077d1 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -236,7 +236,7 @@ export const resolveYes = Effect.gen(function* () { * project env before prompting (`migration down`, `migration repair --all`). Shell env * *presence* (any value) suppresses the file value entirely (see * {@link supabaseEnvBoolWithProjectFallback}); an explicit `--yes` wins over both. `projectEnv` is - * the loaded map from `loadProjectEnv`. + * the loaded map from `loadProjectEnvValues`. */ export const resolveYesWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { @@ -318,7 +318,7 @@ const debugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { /** * `--debug` resolved with an env fallback, and the project `.env` consulted too, for debug-gated - * behavior downstream of a command that already loaded the nested project env. `loadProjectEnv` + * behavior downstream of a command that already loaded the nested project env. `loadProjectEnvValues` * is pure, so callers pass the loaded map through explicitly — same shape as * {@link resolveYesWithProjectEnv}. Shell env *presence* suppresses the file value; an explicit * `--debug` wins over both. diff --git a/apps/cli/src/command-internal/hostname.ts b/apps/cli/src/command-internal/hostname.ts index 9d1a7ac23a..857353d9b1 100644 --- a/apps/cli/src/command-internal/hostname.ts +++ b/apps/cli/src/command-internal/hostname.ts @@ -1,30 +1,23 @@ import { Config, Crypto, Effect, FileSystem, Option, Path, Schema } from "effect"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; +import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { ambientEnvironment } from "../shared/config/cli-config-provider.layer.ts"; const LOCAL_HOST = "127.0.0.1"; const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; const DEFAULT_CONTEXT_NAME = "default"; -type Environment = Readonly>; +// The docker client only sees the shell env, so daemon discovery never reads a project `.env*`. +const shellOption = (name: string): Effect.Effect, Config.ConfigError> => + Config.option(Config.String(name)); -const envOption = ( - name: string, - projectEnvValues?: Environment, -): Effect.Effect, Config.ConfigError> => { - const projectValue = Option.fromNullishOr(projectEnvValues?.[name]); - return Option.isSome(projectValue) - ? Effect.succeed(projectValue) - : Config.option(Config.String(name)); -}; - -const dockerConfigDir = ( - projectEnvValues?: Environment, -): Effect.Effect => +const dockerConfigDir = (): Effect.Effect => Effect.gen(function* () { const runtime = yield* RuntimeInfo; const path = yield* Path.Path; - const configured = yield* envOption("DOCKER_CONFIG", projectEnvValues); + const configured = yield* shellOption("DOCKER_CONFIG"); return Option.getOrElse(configured.pipe(Option.filter((value) => value.length > 0)), () => path.join(runtime.homeDir, ".docker"), ); @@ -51,15 +44,17 @@ const decodeDockerMeta = ( ): Effect.Effect>> => Schema.decodeEffect(Schema.fromJsonString(DockerMetaSchema))(content).pipe(Effect.option); -const currentDockerContextName = ( - projectEnvValues?: Environment, -): Effect.Effect => +const currentDockerContextName = (): Effect.Effect< + string, + Config.ConfigError, + RuntimeInfo | FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { - const fromEnv = yield* envOption("DOCKER_CONTEXT", projectEnvValues); + const fromEnv = yield* shellOption("DOCKER_CONTEXT"); if (Option.isSome(fromEnv) && fromEnv.value.length > 0) return fromEnv.value; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const configDir = yield* dockerConfigDir(projectEnvValues); + const configDir = yield* dockerConfigDir(); const configPath = path.join(configDir, "config.json"); const config = yield* fs.readFileString(configPath).pipe(Effect.option); @@ -73,13 +68,13 @@ const currentDockerContextName = ( return DEFAULT_CONTEXT_NAME; }); -const dockerContextEndpointHost = (contextName: string, projectEnvValues?: Environment) => { +const dockerContextEndpointHost = (contextName: string) => { if (contextName === DEFAULT_CONTEXT_NAME) return Effect.succeed(Option.none()); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const configDir = yield* dockerConfigDir(projectEnvValues); + const configDir = yield* dockerConfigDir(); const digest = yield* crypto .digest("SHA-256", new TextEncoder().encode(contextName)) .pipe(Effect.option); @@ -122,36 +117,35 @@ export function platformDefaultDockerHost(platform: NodeJS.Platform): string { } /** Resolves the daemon endpoint selected by the Docker CLI. */ -export const resolveDockerDaemonEndpoint = ( - projectEnvValues?: Environment, -): Effect.Effect< +export const resolveDockerDaemonEndpoint = Effect.fnUntraced(function* (): Effect.fn.Return< Option.Option, Config.ConfigError, RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto -> => - Effect.gen(function* () { - const runtime = yield* RuntimeInfo; - const dockerHost = yield* envOption("DOCKER_HOST", projectEnvValues); - if (Option.isSome(dockerHost) && dockerHost.value.length > 0) return dockerHost; - const contextName = yield* currentDockerContextName(projectEnvValues); - if (contextName === DEFAULT_CONTEXT_NAME) { - return Option.some(platformDefaultDockerHost(runtime.platform)); - } - return yield* dockerContextEndpointHost(contextName, projectEnvValues); - }); +> { + const runtime = yield* RuntimeInfo; + const dockerHost = yield* shellOption("DOCKER_HOST"); + if (Option.isSome(dockerHost) && dockerHost.value.length > 0) return dockerHost; + const contextName = yield* currentDockerContextName(); + if (contextName === DEFAULT_CONTEXT_NAME) { + return Option.some(platformDefaultDockerHost(runtime.platform)); + } + return yield* dockerContextEndpointHost(contextName); +}); -/** Resolves the hostname used for local Supabase service connections. */ +/** Resolves the hostname for local service connections: `SUPABASE_SERVICES_HOSTNAME` (shell, then project `.env*`), else the shell-selected Docker daemon's tcp host. */ export const getHostname = ( - projectEnvValues?: Environment, + resolvedConfig: Pick, ): Effect.Effect< string, - Config.ConfigError, + Config.ConfigError | CliConfigLoadError, RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto > => Effect.gen(function* () { - const override = yield* envOption("SUPABASE_SERVICES_HOSTNAME", projectEnvValues); - if (Option.isSome(override) && override.value.length > 0) return override.value; - const endpoint = yield* resolveDockerDaemonEndpoint(projectEnvValues); + const { SUPABASE_SERVICES_HOSTNAME: override } = yield* resolvedConfig.envValues([ + "SUPABASE_SERVICES_HOSTNAME", + ]); + if (override !== undefined) return override; + const endpoint = yield* resolveDockerDaemonEndpoint(); if (Option.isSome(endpoint)) { const host = yield* hostFromTcpEndpoint(endpoint.value); if (Option.isSome(host)) return host.value; @@ -160,7 +154,7 @@ export const getHostname = ( }); /** Keeps Bun from proxying the CLI's loopback HTTP requests. */ -export function configureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { +export function configureLoopbackProxyBypass(env: NodeJS.ProcessEnv = ambientEnvironment()): void { const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY"; const current = env[key]; env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY; diff --git a/apps/cli/src/command-internal/hostname.unit.test.ts b/apps/cli/src/command-internal/hostname.unit.test.ts index 712fbdb58b..72d6f8d273 100644 --- a/apps/cli/src/command-internal/hostname.unit.test.ts +++ b/apps/cli/src/command-internal/hostname.unit.test.ts @@ -13,6 +13,7 @@ import { Path, Schema, } from "effect"; +import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { configureLoopbackProxyBypass, @@ -37,6 +38,20 @@ const runtimeLayer = Layer.succeed( type HostnameServices = RuntimeInfo | FileSystem.FileSystem | Path.Path | Crypto.Crypto; +/** A resolved config whose env-tier lookup yields `env`; the real tier order is covered by the config service tests. */ +const resolvedConfigWith = (env: Readonly> = {}) => ({ + envValues: (names: Iterable) => + Effect.succeed( + Object.fromEntries( + [...names].flatMap((name) => { + const value = env[name]; + return value === undefined || value === "" ? [] : [[name, value]]; + }), + ), + ), +}); +const noConfig = resolvedConfigWith(); + function configLayer(env: Readonly>) { return Layer.mergeAll( BunServices.layer, @@ -75,7 +90,7 @@ const writeDockerConfigDir = Effect.fnUntraced(function* (options: { function withDockerConfig( options: Parameters[0], env: Readonly>, - run: () => Effect.Effect, + run: () => Effect.Effect, ) { return writeDockerConfigDir(options).pipe( Effect.provide(BunServices.layer), @@ -86,29 +101,35 @@ function withDockerConfig( } describe("getHostname", () => { - it.effect("prefers a project override and preserves an explicit empty value", () => + it.effect("uses the resolved SUPABASE_SERVICES_HOSTNAME and ignores an empty one", () => Effect.gen(function* () { - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "db.internal" })).toBe("db.internal"); - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "" })).toBe("127.0.0.1"); + expect( + yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "db.internal" })), + ).toBe("db.internal"); + expect(yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "" }))).toBe( + "127.0.0.1", + ); }).pipe(Effect.provide(configLayer({}))), ); - it.effect("reads ambient DOCKER_HOST and extracts IPv4 and IPv6 hosts", () => + it.effect("prefers the resolved hostname over a tcp DOCKER_HOST", () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("docker-host"); - expect(yield* getHostname({})).toBe("docker-host"); - expect(yield* getHostname({ DOCKER_HOST: "tcp://[::1]:2375" })).toBe("::1"); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeLayer, - ConfigProvider.layer( - ConfigProvider.fromEnvRecord({ DOCKER_HOST: "tcp://docker-host:2375" }), - ), - ), - ), - ), + expect( + yield* getHostname(resolvedConfigWith({ SUPABASE_SERVICES_HOSTNAME: "override" })), + ).toBe("override"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), + ); + + it.effect("extracts the host from an ambient tcp DOCKER_HOST", () => + Effect.gen(function* () { + expect(yield* getHostname(noConfig)).toBe("docker-host"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), + ); + + it.effect("extracts an IPv6 host from an ambient tcp DOCKER_HOST", () => + Effect.gen(function* () { + expect(yield* getHostname(noConfig)).toBe("::1"); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://[::1]:2375" }))), ); it.effect("uses currentContext from Docker config", () => @@ -117,7 +138,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("remote-host"); + expect(yield* getHostname(noConfig)).toBe("remote-host"); }), ), ); @@ -131,7 +152,7 @@ describe("getHostname", () => { { DOCKER_CONTEXT: "envctx" }, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("envctx-host"); + expect(yield* getHostname(noConfig)).toBe("envctx-host"); }), ), ); @@ -142,7 +163,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("::1"); + expect(yield* getHostname(noConfig)).toBe("::1"); }), ), ); @@ -153,7 +174,7 @@ describe("getHostname", () => { {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -162,7 +183,7 @@ describe("getHostname", () => { withDockerConfig({ currentContext: "ghost" }, {}, () => Effect.gen(function* () { expect(yield* resolveDockerDaemonEndpoint()).toEqual(Option.none()); - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -170,7 +191,7 @@ describe("getHostname", () => { it.effect("falls back to the default context when Docker config is missing", () => withDockerConfig({}, {}, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -184,7 +205,7 @@ describe("getHostname", () => { expect(yield* resolveDockerDaemonEndpoint()).toEqual( Option.some("unix:///var/run/docker.sock"), ); - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }), ), ); @@ -195,7 +216,7 @@ describe("getHostname", () => { { DOCKER_HOST: "tcp://direct-host:2375" }, () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("direct-host"); + expect(yield* getHostname(noConfig)).toBe("direct-host"); }), ), ); @@ -215,7 +236,7 @@ describe("getHostname", () => { it.effect("returns loopback for an absent or non-tcp endpoint", () => Effect.gen(function* () { - expect(yield* getHostname()).toBe("127.0.0.1"); + expect(yield* getHostname(noConfig)).toBe("127.0.0.1"); }).pipe( Effect.provide( Layer.mergeAll( @@ -233,13 +254,16 @@ describe("getHostname", () => { describe("resolveDockerDaemonEndpoint", () => { it.effect("returns DOCKER_HOST verbatim, including non-tcp schemes", () => Effect.gen(function* () { - expect( - yield* resolveDockerDaemonEndpoint({ DOCKER_HOST: "unix:///custom/engine.sock" }), - ).toEqual(Option.some("unix:///custom/engine.sock")); - expect(yield* resolveDockerDaemonEndpoint({ DOCKER_HOST: "tcp://docker-host:2375" })).toEqual( - Option.some("tcp://docker-host:2375"), + expect(yield* resolveDockerDaemonEndpoint()).toEqual( + Option.some("unix:///custom/engine.sock"), ); - }).pipe(Effect.provide(configLayer({}))), + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "unix:///custom/engine.sock" }))), + ); + + it.effect("returns a tcp DOCKER_HOST verbatim", () => + Effect.gen(function* () { + expect(yield* resolveDockerDaemonEndpoint()).toEqual(Option.some("tcp://docker-host:2375")); + }).pipe(Effect.provide(configLayer({ DOCKER_HOST: "tcp://docker-host:2375" }))), ); it.effect("maps the default context to the platform default daemon endpoint", () => diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index 461162dafa..6776673da7 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -4,7 +4,7 @@ import type { PlatformError } from "effect/PlatformError"; import { CommandSettings } from "../config/command-settings.service.ts"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; -import { tempPaths } from "./temp-paths.ts"; +import { tempPaths } from "../shared/config/temp-paths.ts"; import { fetchGotrueVersion, fetchPostgrestVersion, diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index aaea7a668d..cbd58560a5 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -5,7 +5,7 @@ import { PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { findBranchName } from "./branch-target.ts"; import { type CachedLinkedProject, parseCachedLinkedProject } from "./parent-project-ref.ts"; import { formatNamedRef, sanitizeInlineName } from "./http-errors.ts"; -import { readProjectRefFile, tempPaths } from "./temp-paths.ts"; +import { readProjectRefFile, tempPaths } from "../shared/config/temp-paths.ts"; /** * Discriminated linked-state result. diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index 1ee1bd71c8..1ff763b3f3 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -1,8 +1,6 @@ import { readFileSync } from "node:fs"; -import { basename } from "node:path"; import type { CliConfig } from "@supabase/config"; -import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; import { DEFAULT_LOCAL_DATABASE_PASSWORD, DEFAULT_LOCAL_S3_ACCESS_KEY_ID, @@ -18,12 +16,12 @@ import { import { Effect, Option, Schema } from "effect"; import * as Base64Url from "effect/encoding/Base64Url"; +import { CliConfigValueError } from "../config/cli-config.errors.ts"; import { resolveRemoteJwks, resolveThirdPartyIssuerUrl, thirdPartyIssuerUrlUnchecked, toPublicJwk, - type ThirdPartyProvidersLike, } from "../shared/auth/jwks.ts"; import { actionability, @@ -32,8 +30,7 @@ import { ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; import { resolveApiExternalUrl } from "./api-url.ts"; -import { makeRemoteWins, type RemoteOverridableKey } from "./db-config.toml-read.ts"; -import { sanitizeProjectId } from "./docker-ids.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; import { apiTlsCertReadErrorMessage, apiTlsKeyReadErrorMessage, @@ -49,7 +46,6 @@ import { type HookInput, type LocalSmtpInput, type MfaFactorInput, - parseBoolLiteral, type PasskeyInput, resolveApiTlsPath, resolveEmailTemplateContentPath, @@ -59,46 +55,40 @@ import { type SmtpInput, type StudioInput, type ThirdPartyInput, + validateAuthExternalProviders, + validateAuthSmsProviders, validateResolvedConfig, } from "./config-validate.ts"; +import { parseBoolLiteral } from "../shared/config/config-bool.ts"; import { DEFAULT_SIGNING_KEY, generateAsymmetricLocalJwt, generateLocalJwt, type Jwk, } from "./local-jwt.ts"; -import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; /** - * Resolves local-dev config values (URLs, ports, keys) for `status`/`stop`, filling in - * literal defaults for fields `@supabase/config`'s schema doesn't model (`db.password`, - * the S3 credential triple). Kept separate from `storage-credentials.ts`, which resolves - * credentials for remote projects over HTTP instead. + * Resolves local-dev config values (URLs, ports, keys) from the effective config the + * `CliConfigValues` resolved config materializes: every flag, environment, project `.env*` and matched + * `[remotes.*]` winner is already in `config`, and `document` is the matching effective document + * (see `effectiveConfigDocument`), so nothing here re-applies precedence. */ -/** Local Postgres password; not configurable via config.toml. */ const DEFAULT_DB_PASSWORD = DEFAULT_LOCAL_DATABASE_PASSWORD; -/** Local S3 credentials; not configurable via config.toml. */ const DEFAULT_S3_ACCESS_KEY_ID = DEFAULT_LOCAL_S3_ACCESS_KEY_ID; const DEFAULT_S3_SECRET_ACCESS_KEY = DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY; const DEFAULT_S3_REGION = DEFAULT_LOCAL_S3_REGION; -/** - * Default Postgres root key. Exported so `start`'s Postgres container-spec builder can - * reuse this literal instead of duplicating it; `db.root_key` isn't modeled in - * `@supabase/config`'s schema, so it's resolved from the raw document like `jwtSecret`. - */ +/** Shared with `start`'s Postgres container-spec builder. */ export const POSTGRES_DEFAULT_ROOT_KEY = DEFAULT_POSTGRES_ROOT_KEY; export interface LocalConfigValues { readonly apiUrl: string; readonly apiPort: number; readonly dbPort: number; - /** Already env-overridden `studio.port` (`SUPABASE_STUDIO_PORT`) — see `apiPort`/`dbPort` for the same pattern. */ readonly studioPort: number; readonly rootKey: string; - /** Already-resolved `studio.openai_api_key`: env-overridden, then decrypted if `encrypted:`. */ readonly openaiApiKey: string | undefined; readonly authSiteUrl: string; readonly authJwtIssuer: string | undefined; @@ -127,22 +117,13 @@ export interface LocalConfigValues { readonly storageS3AccessKeyId: string; readonly storageS3SecretAccessKey: string; readonly storageS3Region: string; - /** Already env-overridden `analytics.enabled` (`SUPABASE_ANALYTICS_ENABLED`). */ readonly analyticsEnabled: boolean; - /** Already env-overridden `analytics.backend` (`SUPABASE_ANALYTICS_BACKEND`), hard-validated like `LogflareBackend`. */ readonly analyticsBackend: "postgres" | "bigquery"; - /** Already env-overridden `analytics.gcp_project_id` (`SUPABASE_ANALYTICS_GCP_PROJECT_ID`). */ readonly gcpProjectId: string; - /** Already env-overridden `analytics.gcp_project_number` (`SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER`). */ readonly gcpProjectNumber: string; - /** Already env-overridden `analytics.gcp_jwt_path` (`SUPABASE_ANALYTICS_GCP_JWT_PATH`). */ readonly gcpJwtPath: string; - /** - * Sanitized, env-overridden project ID — the same value already validated internally, - * exposed so callers needing it for Docker resource naming don't re-derive it separately. - */ + /** Sanitized project ID, exposed so callers naming Docker resources don't re-derive it. */ readonly projectId: string; - /** Already env-overridden `edge_runtime.deno_version` (`SUPABASE_EDGE_RUNTIME_DENO_VERSION`). */ readonly edgeRuntimeDenoVersion: number; } @@ -169,443 +150,71 @@ export class InvalidJwtSecretError extends Error { /** Minimum `auth.jwt_secret` length. */ const MIN_JWT_SECRET_LENGTH = 16; -/** - * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. A malformed port override always hard-fails config loading; there's - * no path that reaches `status`/`stop` with one. - */ -export class InvalidPortEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidPortEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: "${value}" is not a valid port`); - this.name = "InvalidPortEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** Maximum valid port number. */ -const MAX_PORT = 65535; - -/** - * Port-flavored sibling of {@link envOverride}/{@link envOverrideBool}. Unlike the boolean - * sibling, which falls back to the configured value on a malformed override, a bad port - * override throws {@link InvalidPortEnvOverrideError} instead. - */ -export function envOverridePort( - name: string, - configuredPort: number, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configuredPort; - const parsed = parseBaseZeroUint(value); - if (parsed === undefined || parsed > BigInt(MAX_PORT)) { - throw new InvalidPortEnvOverrideError(dottedFieldPath, value); - } - return Number(parsed); -} - -/** - * Resolves a `SUPABASE_` override for a config field: checks `projectEnvValues` - * (the project's already-resolved dotenv values) before falling back to `process.env`, - * treating an empty value as unset. - * - * The resolved value can itself be an `env(VAR)` indirection (e.g. `env(API_ENABLED)`), - * resolved with the same precedence; an unresolved indirection is returned as-is. - */ -export function envOverride( - name: string, +/** Narrows a configured string to one of `allowed`, failing with the codec wording otherwise. */ +export const narrowConfigEnum = ( + path: string, configured: string, - projectEnvValues: Readonly> | undefined, -): string; -export function envOverride( - name: string, - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined; -export 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 envOverrideBool} when a `SUPABASE_*_ENABLED` (or other bool-typed) - * env/dotenv override doesn't parse as an accepted bool spelling. A malformed bool override - * always hard-fails config loading, same as {@link InvalidPortEnvOverrideError}. - */ -export class InvalidBoolEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidBoolEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: "${value}" is not a valid boolean`); - this.name = "InvalidBoolEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields decoded as a - * native bool (`api.tls.enabled`, `auth.enabled`, and other `
.enabled` gates). A - * malformed override throws {@link InvalidBoolEnvOverrideError} rather than falling back - * to `configured`. - */ -export function envOverrideBool( - name: string, - configured: boolean, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): boolean { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseBoolLiteral(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; -} - -/** Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` isn't `"postgres"` or `"bigquery"`. */ -export class InvalidAnalyticsBackendEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidAnalyticsBackendEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: "${value}" must be one of "postgres", "bigquery"`, - ); - this.name = "InvalidAnalyticsBackendEnvOverrideError"; - } - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Validates the override-or-configured value against the closed `postgres`/`bigquery` set, - * checking both sources with a single check so the return type narrows correctly. - * `skipEnvOverride` lets a matched remote-config value win over a conflicting - * `SUPABASE_ANALYTICS_BACKEND`. - */ -export function envOverrideAnalyticsBackend( - configured: string, - projectEnvValues: Readonly> | undefined, - skipEnvOverride = false, -): "postgres" | "bigquery" { - const value = skipEnvOverride - ? configured - : (envOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured); - if (value !== "postgres" && value !== "bigquery") { - throw new InvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); - } - return value; -} - -/** Thrown by {@link envOverrideRealtimeIpVersion} when `SUPABASE_REALTIME_IP_VERSION` isn't `"IPv4"` or `"IPv6"`. */ -export class InvalidRealtimeIpVersionEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidRealtimeIpVersionEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: "${value}" must be one of "IPv4", "IPv6"`); - this.name = "InvalidRealtimeIpVersionEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverrideRealtimeIpVersion( - configured: string, - projectEnvValues: Readonly> | undefined, -): "IPv4" | "IPv6" { - const value = - envOverride("SUPABASE_REALTIME_IP_VERSION", undefined, projectEnvValues) ?? configured; - if (value !== "IPv4" && value !== "IPv6") { - throw new InvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); - } - return value; -} - -/** `SUPABASE_REALTIME_MAX_HEADER_LENGTH` — see {@link envOverrideUint}. */ -export function envOverrideRealtimeMaxHeaderLength( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_REALTIME_MAX_HEADER_LENGTH", - "realtime.max_header_length", - configured, - projectEnvValues, - ); -} - -/** `SUPABASE_API_MAX_ROWS` — see {@link envOverrideUint}. */ -export function envOverrideApiMaxRows( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint("SUPABASE_API_MAX_ROWS", "api.max_rows", configured, projectEnvValues); -} - -/** Thrown by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` isn't `"transaction"` or `"session"`. */ -export class InvalidPoolModeEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidPoolModeEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: "${value}" must be one of "transaction", "session"`, - ); - this.name = "InvalidPoolModeEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverridePoolMode( - configured: string, - projectEnvValues: Readonly> | undefined, -): "transaction" | "session" { - const value = - envOverride("SUPABASE_DB_POOLER_POOL_MODE", undefined, projectEnvValues) ?? configured; - if (value !== "transaction" && value !== "session") { - throw new InvalidPoolModeEnvOverrideError("db.pooler.pool_mode", value); - } - return value; -} - -/** Thrown by {@link envOverrideEdgeRuntimePolicy} when `SUPABASE_EDGE_RUNTIME_POLICY` isn't `"per_worker"` or `"oneshot"`. */ -export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "InvalidEdgeRuntimePolicyEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: "${value}" must be one of "per_worker", "oneshot"`, - ); - this.name = "InvalidEdgeRuntimePolicyEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export function envOverrideEdgeRuntimePolicy( - configured: string, - projectEnvValues: Readonly> | undefined, -): "per_worker" | "oneshot" { - const value = - envOverride("SUPABASE_EDGE_RUNTIME_POLICY", undefined, projectEnvValues) ?? configured; - if (value !== "per_worker" && value !== "oneshot") { - throw new InvalidEdgeRuntimePolicyEnvOverrideError("edge_runtime.policy", value); + allowed: ReadonlyArray, +): T => { + const match = allowed.find((candidate) => candidate === configured); + if (match === undefined) { + throw new CliConfigValueError({ + path, + tier: "config", + message: `Invalid config for ${path}: "${configured}" must be one of ${allowed + .map((value) => `"${value}"`) + .join(", ")}`, + }); } - return value; -} - -/** `SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE` — see {@link envOverrideUint}. */ -export function envOverrideDefaultPoolSize( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", - "db.pooler.default_pool_size", - configured, - projectEnvValues, - ); -} + return match; +}; -/** `SUPABASE_DB_POOLER_MAX_CLIENT_CONN` — see {@link envOverrideUint}. */ -export function envOverrideMaxClientConn( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_POOLER_MAX_CLIENT_CONN", - "db.pooler.max_client_conn", - configured, - projectEnvValues, - ); +/** Narrows an unknown value to a plain object. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; } -/** - * Decrypts a resolved auth identity-key field when it's a dotenvx `encrypted:` value; an - * undecryptable value fails config loading rather than passing through unusable key material. - * Applied after {@link envOverride} so an env-sourced override is decrypted too, not just - * the config.toml value. - */ -export function decryptAuthSecret( - value: string | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined { - if (value === undefined || !isEncryptedSecret(value)) return value; - const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnvValues, ...process.env }); - const decrypted = decryptSecret(value, dotenvPrivateKeys); - if (!decrypted.ok) { - throw new ConfigValidateError(`failed to parse config: ${decrypted.error}`); - } - return decrypted.value; -} +const asString = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; /** * Resolves `[auth.email.smtp]`'s full field set, including a presence-based `enabled` * default `@supabase/config`'s schema can't express (it always decodes `enabled: false` - * when the key is absent from a present table). Exported so `start.handler.ts`'s GoTrue env - * resolution reuses this instead of re-deriving it. + * when the key is absent from a present table). */ export function resolveAuthEmailSmtp( authDocument: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool`/`envOverridePort` calls below can throw on - * a malformed value (directly, or via {@link decryptAuthSecret}) belong in this set so a - * matched remote block's value wins instead of aborting on an unrelated bad env var. - * Defaults to empty for callers with no `[remotes.]` block to match against. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): (SmtpInput & { readonly senderName: string | undefined }) | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); if (smtpDoc === undefined) return undefined; return { - enabled: remoteWins("auth.email.smtp.enabled") - ? smtpDoc["enabled"] === undefined - ? true - : smtpDoc["enabled"] === true - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", - smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, - "auth.email.smtp.enabled", - projectEnvValues, - ), - host: remoteWins("auth.email.smtp.host") - ? typeof smtpDoc["host"] === "string" - ? smtpDoc["host"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_HOST", - typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", - projectEnvValues, - ) ?? ""), - port: remoteWins("auth.email.smtp.port") - ? typeof smtpDoc["port"] === "number" - ? smtpDoc["port"] - : 0 - : envOverridePort( - "SUPABASE_AUTH_EMAIL_SMTP_PORT", - typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, - "auth.email.smtp.port", - projectEnvValues, - ), - user: remoteWins("auth.email.smtp.user") - ? typeof smtpDoc["user"] === "string" - ? smtpDoc["user"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_USER", - typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", - projectEnvValues, - ) ?? ""), - // Decrypted like other secrets. `auth.email.smtp.pass` is in `ENV_OVERRIDABLE_KEYS` so a - // malformed ambient override can't abort decryption when a remote block already set it. - pass: remoteWins("auth.email.smtp.pass") - ? (decryptAuthSecret( - typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", - projectEnvValues, - ) ?? "") - : (decryptAuthSecret( - envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_PASS", - typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", - projectEnvValues, - ) ?? "", - projectEnvValues, - ) ?? ""), - adminEmail: remoteWins("auth.email.smtp.admin_email") - ? typeof smtpDoc["admin_email"] === "string" - ? smtpDoc["admin_email"] - : "" - : (envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", - typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", - projectEnvValues, - ) ?? ""), - senderName: remoteWins("auth.email.smtp.sender_name") - ? typeof smtpDoc["sender_name"] === "string" - ? smtpDoc["sender_name"] - : undefined - : envOverride( - "SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", - typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined, - projectEnvValues, - ), + enabled: smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + host: asString(smtpDoc["host"]) ?? "", + port: typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + user: asString(smtpDoc["user"]) ?? "", + pass: asString(smtpDoc["pass"]) ?? "", + adminEmail: asString(smtpDoc["admin_email"]) ?? "", + senderName: asString(smtpDoc["sender_name"]), }; } /** - * `auth.captcha` validation requires both `provider` and `secret` when the section is - * enabled. Unlike `auth.passkey`/`auth.webauthn`, `config.auth.captcha` never decodes to - * `undefined` when `[auth.captcha]` is absent, so presence is read from the raw - * `authDocument` instead. Hoisted so `start.handler.ts`'s GoTrue env resolution shares this - * same resolved value. + * `config.auth.captcha` never decodes to `undefined` when `[auth.captcha]` is absent, so + * callers that need presence read the document; this only reshapes the effective value. */ export function resolveAuthCaptcha( - authDocument: Readonly> | undefined, captcha: CliConfig["auth"]["captcha"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool` calls below can throw on a malformed value - * (directly, or via {@link decryptAuthSecret}) belong in this set so a matched remote - * block's value wins instead of aborting on an unrelated bad env var. Defaults to empty for - * callers with no `[remotes.]` block to match against. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): CaptchaInput | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const captchaDoc = asRecord(authDocument?.["captcha"]); return captcha - ? { - enabled: remoteWins("auth.captcha.enabled") - ? (captcha.enabled ?? false) - : captchaDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_CAPTCHA_ENABLED", - captcha.enabled ?? false, - "auth.captcha.enabled", - projectEnvValues, - ) - : (captcha.enabled ?? false), - provider: remoteWins("auth.captcha.provider") - ? captcha.provider - : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues) - : captcha.provider, - // Decrypted like `auth.email.smtp.pass`. `auth.captcha.secret` is in - // `ENV_OVERRIDABLE_KEYS` so a malformed ambient override can't abort decryption when a - // remote block already set it. - secret: decryptAuthSecret( - remoteWins("auth.captcha.secret") - ? captcha.secret - : captchaDoc !== undefined - ? envOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) - : captcha.secret, - projectEnvValues, - ), - } + ? { enabled: captcha.enabled ?? false, provider: captcha.provider, secret: captcha.secret } : undefined; } /** - * Resolves the signing secret from the already-resolved `auth.jwt_secret`: empty falls back - * to {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws + * Resolves the signing secret from the effective `auth.jwt_secret`: empty falls back to + * {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws * {@link InvalidJwtSecretError}. */ export function resolveJwtSecret(configured: string | undefined): string { @@ -683,42 +292,19 @@ function readSigningKeysFile(workdir: string, signingKeysPath: string): Readonly } } -/** See {@link readSigningKeysFile}. */ -function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArray { - return readSigningKeysFile(workdir, signingKeysPath); -} - /** * Returns the parsed signing keys only when auth is enabled and a path is configured; * `undefined` otherwise, so callers fall back to their own default key shape. Shared by * {@link resolveLocalJwks} and `start.handler.ts`'s `GOTRUE_JWT_KEYS` so both resolvers agree - * on which key(s) apply. `remoteOverrideKeys` lets a matched remote block win over a - * conflicting env override on `auth.enabled`/`auth.signing_keys_path`. + * on which key(s) apply. */ export function resolveConfiguredSigningKeys( config: CliConfig, workdir: string, - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 - ? loadSigningKeys(workdir, signingKeysPath) + const signingKeysPath = config.auth.signing_keys_path; + return config.auth.enabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? readSigningKeysFile(workdir, signingKeysPath) : undefined; } @@ -748,9 +334,8 @@ function readApiTlsFiles( } /** - * One `[auth.email.template.]` entry, already env-override-resolved. `subject` is - * `string | undefined` rather than a plain `string` — see {@link resolveAuthEmail}'s doc - * comment for why. + * One `[auth.email.template.]` entry. `subject` is `string | undefined` rather than a plain + * `string` — see {@link resolveAuthEmail} for why. */ interface ResolvedAuthEmailTemplate { readonly subject: string | undefined; @@ -777,139 +362,40 @@ export type ResolvedAuthEmail = Omit | undefined, - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverride`/`envOverrideBool` calls (here or in the caller-side file read - * for `content_path`) can throw on a malformed value belong in this set so a matched remote - * block's value wins instead of aborting on an unrelated bad env var. Covers top-level - * `auth.email.*` leaves as well as each `template..*`/`notification..*` leaf. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): ResolvedAuthEmail { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const emailDoc = asRecord(authDocument?.["email"]); const templateDoc = asRecord(emailDoc?.["template"]); const notificationDoc = asRecord(emailDoc?.["notification"]); const template: Record = {}; for (const [name, tmpl] of Object.entries(email.template)) { - const envPrefix = `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}`; - const rawSubjectPresent = asRecord(templateDoc?.[name])?.["subject"] !== undefined; - const envSubject = remoteWins(`auth.email.template.${name}.subject`) - ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + const entry = asRecord(templateDoc?.[name]); template[name] = { - subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: remoteWins(`auth.email.template.${name}.content_path`) - ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path), - // `content` counts as present when set via TOML or the `_CONTENT` env override; - // {@link readAuthEmailTemplateContent} rejects it below unless `content_path` is also set. - content_present: - asRecord(templateDoc?.[name])?.["content"] !== undefined || - (remoteWins(`auth.email.template.${name}.content`) - ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + subject: entry?.["subject"] !== undefined ? tmpl.subject : undefined, + content_path: tmpl.content_path, + content_present: entry?.["content"] !== undefined, }; } const notification: Record = {}; for (const [name, tmpl] of Object.entries(email.notification)) { - const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; - const rawSubjectPresent = asRecord(notificationDoc?.[name])?.["subject"] !== undefined; - const envSubject = remoteWins(`auth.email.notification.${name}.subject`) - ? undefined - : envOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); + const entry = asRecord(notificationDoc?.[name]); notification[name] = { - enabled: remoteWins(`auth.email.notification.${name}.enabled`) - ? tmpl.enabled - : envOverrideBool( - `${envPrefix}_ENABLED`, - tmpl.enabled, - `auth.email.notification.${name}.enabled`, - projectEnvValues, - ), - subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: remoteWins(`auth.email.notification.${name}.content_path`) - ? tmpl.content_path - : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path), - // Same `_CONTENT` env-presence fold as the template loop above. - content_present: - asRecord(notificationDoc?.[name])?.["content"] !== undefined || - (remoteWins(`auth.email.notification.${name}.content`) - ? false - : envOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), + enabled: tmpl.enabled, + subject: entry?.["subject"] !== undefined ? tmpl.subject : undefined, + content_path: tmpl.content_path, + content_present: entry?.["content"] !== undefined, }; } - return { - ...email, - enable_signup: remoteWins("auth.email.enable_signup") - ? email.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", - email.enable_signup, - "auth.email.enable_signup", - projectEnvValues, - ), - double_confirm_changes: remoteWins("auth.email.double_confirm_changes") - ? email.double_confirm_changes - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", - email.double_confirm_changes, - "auth.email.double_confirm_changes", - projectEnvValues, - ), - enable_confirmations: remoteWins("auth.email.enable_confirmations") - ? email.enable_confirmations - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", - email.enable_confirmations, - "auth.email.enable_confirmations", - projectEnvValues, - ), - secure_password_change: remoteWins("auth.email.secure_password_change") - ? email.secure_password_change - : envOverrideBool( - "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", - email.secure_password_change, - "auth.email.secure_password_change", - projectEnvValues, - ), - max_frequency: remoteWins("auth.email.max_frequency") - ? email.max_frequency - : (envOverride("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", email.max_frequency, projectEnvValues) ?? - email.max_frequency), - otp_length: remoteWins("auth.email.otp_length") - ? email.otp_length - : envOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_LENGTH", - "auth.email.otp_length", - email.otp_length, - projectEnvValues, - ), - otp_expiry: remoteWins("auth.email.otp_expiry") - ? email.otp_expiry - : envOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", - "auth.email.otp_expiry", - email.otp_expiry, - projectEnvValues, - ), - template, - notification, - }; + return { ...email, template, notification }; } /** @@ -952,392 +438,18 @@ function readAuthEmailTemplateContent(email: ResolvedAuthEmail, workdir: string) } } -// Every field routed through {@link envOverrideUint} is an unsigned 64-bit value; comparing -// as `BigInt` (not `Number`) avoids precision loss near `2^64`. -const UINT_MAX = 18446744073709551615n; // 2^64 - 1 - -/** - * Base-0 unsigned integer literal parsing (`0b`/`0o`/`0x` prefixes, and a bare leading zero - * also meaning octal — so `"010"` parses as `8`). Underscores between digits are allowed; a - * leading sign is never accepted. Returns `undefined` for anything invalid instead of throwing, - * leaving bit-width bounds to the caller. - */ -function parseBaseZeroUint(value: string): bigint | undefined { - if (value.length === 0 || value.startsWith("+") || value.startsWith("-")) return undefined; - - let literal: string | undefined; - if (/^0[bB](_?[01])+$/.test(value)) { - literal = `0b${value.slice(2).replaceAll("_", "")}`; - } else if (/^0[oO](_?[0-7])+$/.test(value)) { - literal = `0o${value.slice(2).replaceAll("_", "")}`; - } else if (/^0[xX](_?[0-9a-fA-F])+$/.test(value)) { - literal = `0x${value.slice(2).replaceAll("_", "")}`; - } else if (value.startsWith("0") && value.length > 1) { - // A bare leading zero is always octal, with no fallback to decimal — `"08"`/`"09"` are - // rejected, not read as decimal 8/9. - literal = /^[0-7](_?[0-7])*$/.test(value) ? `0o${value.replaceAll("_", "")}` : undefined; - } else { - literal = /^[0-9](_?[0-9])*$/.test(value) ? value.replaceAll("_", "") : undefined; - } - if (literal === undefined) return undefined; - - try { - // `BigInt` natively parses `0b`/`0o`/`0x`-prefixed literals in the corresponding base. - return BigInt(literal); - } catch { - return undefined; - } -} - -/** - * `SUPABASE_` sibling of {@link envOverridePort} for uncapped `uint`-typed fields - * (`db.major_version`, `auth.jwt_expiry`, …). Parses with {@link parseBaseZeroUint} and - * folds an invalid or out-of-{@link UINT_MAX} override into the generic "Invalid " - * error message. - */ -export function envOverrideUint( - name: string, - dottedFieldPath: string, - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseBaseZeroUint(value); - if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); - } - return Number(parsed); -} - -/** `SUPABASE_DB_MAJOR_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideMajorVersion( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_DB_MAJOR_VERSION", - "db.major_version", - configured, - projectEnvValues, - ); -} - -/** `SUPABASE_EDGE_RUNTIME_DENO_VERSION` — see {@link envOverrideUint}. */ -export function envOverrideDenoVersion( - configured: number, - projectEnvValues: Readonly> | undefined, -): number { - return envOverrideUint( - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "edge_runtime.deno_version", - configured, - projectEnvValues, - ); -} - -/** - * Optional-uint sibling of {@link envOverrideUint} for `db.settings.*` fields - * (`max_connections`, `max_wal_senders`, …) that are left unset, not defaulted, when - * absent from config.toml. `configured`/the return value stay `number | undefined` to - * preserve that state through an override miss. - */ -function envOverrideOptionalUint( - name: string, - dottedFieldPath: string, - configured: number | undefined, - projectEnvValues: Readonly> | undefined, -): number | undefined { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseBaseZeroUint(value); - if (parsed === undefined || parsed > UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); - } - return Number(parsed); -} - -/** - * Optional-bool sibling of {@link envOverrideBool} for `db.settings.track_commit_timestamp`, - * the only bool field in this file left unset (not defaulted) when absent. - */ -function envOverrideOptionalBool( - name: string, - configured: boolean | undefined, - dottedFieldPath: string, - projectEnvValues: Readonly> | undefined, -): boolean | undefined { - const value = envOverride(name, undefined, projectEnvValues); - if (value === undefined) return configured; - const parsed = parseBoolLiteral(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; -} - -/** Thrown by {@link resolveDbSettingsEnvOverrides} when `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` isn't `"origin"`, `"replica"`, or `"local"`. */ -export class InvalidSessionReplicationRoleEnvOverrideError extends Error { - static readonly [ErrorActionabilityFingerprintId] = - "InvalidSessionReplicationRoleEnvOverrideError"; - constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: "${value}" must be one of "origin", "replica", "local"`, - ); - this.name = "InvalidSessionReplicationRoleEnvOverrideError"; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Enum-flavored sibling of {@link envOverride} for `db.settings.session_replication_role`. - * Unlike {@link envOverrideAnalyticsBackend}, `configured` (and the return value) may - * be `undefined` — validation only runs once a value is actually present. - */ -function envOverrideSessionReplicationRole( - configured: string | undefined, - projectEnvValues: Readonly> | undefined, -): "origin" | "replica" | "local" | undefined { - const value = envOverride( - "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", - configured, - projectEnvValues, - ); - if (value === undefined) return undefined; - if (value !== "origin" && value !== "replica" && value !== "local") { - throw new InvalidSessionReplicationRoleEnvOverrideError( - "db.settings.session_replication_role", - value, - ); - } - return value; -} - -/** - * Resolves every `db.settings.*` sub-field to its env-overridden value before - * `postgresSettingsToPostgresConfig` serializes `postgresql.conf`. `remoteOverrideKeys` - * lets a matched remote block's value win over a conflicting `SUPABASE_DB_SETTINGS_*` var, - * same as elsewhere in this file. - */ +/** The effective `db.settings`, never `undefined` so callers can read fields directly. */ export function resolveDbSettingsEnvOverrides( settings: CliConfig["db"]["settings"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): NonNullable { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - return { - effective_cache_size: remoteWins("db.settings.effective_cache_size") - ? settings?.effective_cache_size - : envOverride( - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - settings?.effective_cache_size, - projectEnvValues, - ), - logical_decoding_work_mem: remoteWins("db.settings.logical_decoding_work_mem") - ? settings?.logical_decoding_work_mem - : envOverride( - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - settings?.logical_decoding_work_mem, - projectEnvValues, - ), - maintenance_work_mem: remoteWins("db.settings.maintenance_work_mem") - ? settings?.maintenance_work_mem - : envOverride( - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - settings?.maintenance_work_mem, - projectEnvValues, - ), - max_connections: remoteWins("db.settings.max_connections") - ? settings?.max_connections - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "db.settings.max_connections", - settings?.max_connections, - projectEnvValues, - ), - max_locks_per_transaction: remoteWins("db.settings.max_locks_per_transaction") - ? settings?.max_locks_per_transaction - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "db.settings.max_locks_per_transaction", - settings?.max_locks_per_transaction, - projectEnvValues, - ), - max_parallel_maintenance_workers: remoteWins("db.settings.max_parallel_maintenance_workers") - ? settings?.max_parallel_maintenance_workers - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "db.settings.max_parallel_maintenance_workers", - settings?.max_parallel_maintenance_workers, - projectEnvValues, - ), - max_parallel_workers: remoteWins("db.settings.max_parallel_workers") - ? settings?.max_parallel_workers - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "db.settings.max_parallel_workers", - settings?.max_parallel_workers, - projectEnvValues, - ), - max_parallel_workers_per_gather: remoteWins("db.settings.max_parallel_workers_per_gather") - ? settings?.max_parallel_workers_per_gather - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "db.settings.max_parallel_workers_per_gather", - settings?.max_parallel_workers_per_gather, - projectEnvValues, - ), - max_replication_slots: remoteWins("db.settings.max_replication_slots") - ? settings?.max_replication_slots - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "db.settings.max_replication_slots", - settings?.max_replication_slots, - projectEnvValues, - ), - max_slot_wal_keep_size: remoteWins("db.settings.max_slot_wal_keep_size") - ? settings?.max_slot_wal_keep_size - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - settings?.max_slot_wal_keep_size, - projectEnvValues, - ), - max_standby_archive_delay: remoteWins("db.settings.max_standby_archive_delay") - ? settings?.max_standby_archive_delay - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - settings?.max_standby_archive_delay, - projectEnvValues, - ), - max_standby_streaming_delay: remoteWins("db.settings.max_standby_streaming_delay") - ? settings?.max_standby_streaming_delay - : envOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - settings?.max_standby_streaming_delay, - projectEnvValues, - ), - max_wal_size: remoteWins("db.settings.max_wal_size") - ? settings?.max_wal_size - : envOverride("SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", settings?.max_wal_size, projectEnvValues), - max_wal_senders: remoteWins("db.settings.max_wal_senders") - ? settings?.max_wal_senders - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "db.settings.max_wal_senders", - settings?.max_wal_senders, - projectEnvValues, - ), - max_worker_processes: remoteWins("db.settings.max_worker_processes") - ? settings?.max_worker_processes - : envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "db.settings.max_worker_processes", - settings?.max_worker_processes, - projectEnvValues, - ), - session_replication_role: remoteWins("db.settings.session_replication_role") - ? settings?.session_replication_role - : envOverrideSessionReplicationRole(settings?.session_replication_role, projectEnvValues), - shared_buffers: remoteWins("db.settings.shared_buffers") - ? settings?.shared_buffers - : envOverride( - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - settings?.shared_buffers, - projectEnvValues, - ), - statement_timeout: remoteWins("db.settings.statement_timeout") - ? settings?.statement_timeout - : envOverride( - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - settings?.statement_timeout, - projectEnvValues, - ), - track_activity_query_size: remoteWins("db.settings.track_activity_query_size") - ? settings?.track_activity_query_size - : envOverride( - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - settings?.track_activity_query_size, - projectEnvValues, - ), - track_commit_timestamp: remoteWins("db.settings.track_commit_timestamp") - ? settings?.track_commit_timestamp - : envOverrideOptionalBool( - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - settings?.track_commit_timestamp, - "db.settings.track_commit_timestamp", - projectEnvValues, - ), - wal_keep_size: remoteWins("db.settings.wal_keep_size") - ? settings?.wal_keep_size - : envOverride( - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - settings?.wal_keep_size, - projectEnvValues, - ), - wal_sender_timeout: remoteWins("db.settings.wal_sender_timeout") - ? settings?.wal_sender_timeout - : envOverride( - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - settings?.wal_sender_timeout, - projectEnvValues, - ), - work_mem: remoteWins("db.settings.work_mem") - ? settings?.work_mem - : envOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), - }; -} - -/** `password_requirements` fixed enum (`@supabase/config`'s `packages/config/src/auth/index.ts`). */ -const PASSWORD_REQUIREMENTS_VALUES = new Set([ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols", -]); - -/** - * Enum-flavored sibling of {@link envOverride} for `auth.password_requirements`. Exported so - * `db start`'s own eager-validation battery can call it directly instead of duplicating the - * check. - */ -export function envOverrideAuthPasswordRequirements( - configured: string, - projectEnvValues: Readonly> | undefined, -): string { - const override = envOverride("SUPABASE_AUTH_PASSWORD_REQUIREMENTS", undefined, projectEnvValues); - if (override !== undefined && !PASSWORD_REQUIREMENTS_VALUES.has(override)) { - throw new Error(`Failed reading config: Invalid auth.password_requirements: ${override}.`); - } - return override ?? configured; -} - -/** Narrows an unknown value to a plain object. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; + return settings ?? {}; } -/** - * Resolves `auth.external_url`, not modeled in `@supabase/config`'s schema, from the raw - * document. Shared by `supabase start`'s long-running GoTrue container and `db start`'s - * one-shot auth migration job so both resolve the same value. `remoteOverrideKeys` lets a - * matched remote block win over a conflicting `SUPABASE_AUTH_EXTERNAL_URL`. - */ +/** Resolves `auth.external_url`, which the schema doesn't model, from the effective document. */ export function resolveAuthExternalUrl( document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): string | undefined { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; - const configuredAuthExternalUrl = - typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined; - if (remoteWins("auth.external_url")) return configuredAuthExternalUrl; - return envOverride("SUPABASE_AUTH_EXTERNAL_URL", configuredAuthExternalUrl, projectEnvValues); + return asString(asRecord(document?.["auth"])?.["external_url"]); } /** Hook-type iteration order for {@link resolveAuthHooks}'s output. */ @@ -1370,246 +482,45 @@ export type ResolvedAuthHooks = { readonly [K in (typeof HOOK_TYPE_TO_CAMEL)[keyof typeof HOOK_TYPE_TO_CAMEL]]: ResolvedAuthHook; }; -/** - * Resolves `auth.hook..*` overrides in {@link HOOK_TYPE_ORDER}. Each type's presence in - * the raw `authDocument` gates whether an env override applies, since the schema always - * decodes a `{ enabled: false }` default regardless of file presence. Hoisted so both - * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same - * result. - */ -export function resolveAuthHooks( - authDocument: Readonly> | undefined, - hook: CliConfig["auth"]["hook"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose overrides below can throw or otherwise cause a downstream rejection on a - * malformed value belong in this set so a matched remote block's value wins instead. A - * matched remote flattens the whole `auth.hook.` block, so every leaf - * (`enabled`/`uri`/`secrets`) is included, not just `enabled`. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): ResolvedAuthHooks { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const hookDocument = asRecord(authDocument?.["hook"]); +/** Reshapes the effective `auth.hook.` entries, with absent `uri`/`secrets` as `""`. */ +export function resolveAuthHooks(hook: CliConfig["auth"]["hook"]): ResolvedAuthHooks { const result = {} as Record; for (const hookType of HOOK_TYPE_ORDER) { const h = hook[hookType]; - const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; - const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; - const enabled = remoteWins(`auth.hook.${hookType}.enabled`) - ? h.enabled - : hookSectionPresent - ? envOverrideBool( - `${envPrefix}_ENABLED`, - h.enabled, - `auth.hook.${hookType}.enabled`, - projectEnvValues, - ) - : h.enabled; - const uri = - (remoteWins(`auth.hook.${hookType}.uri`) - ? h.uri - : hookSectionPresent - ? envOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) - : h.uri) ?? ""; - const secrets = - (remoteWins(`auth.hook.${hookType}.secrets`) - ? h.secrets - : hookSectionPresent - ? envOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) - : h.secrets) ?? ""; - result[HOOK_TYPE_TO_CAMEL[hookType]] = { enabled, uri, secrets }; + result[HOOK_TYPE_TO_CAMEL[hookType]] = { + enabled: h.enabled, + uri: h.uri ?? "", + secrets: h.secrets ?? "", + }; } return result as ResolvedAuthHooks; } -/** - * Resolves `auth.mfa`'s per-factor fields. Unlike hooks/smtp, `auth.mfa.` is always - * bound (no schema presence gap), so overrides always apply. Hoisted so both - * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same - * result. - */ -export function resolveAuthMfa( - mfa: CliConfig["auth"]["mfa"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`envOverrideUint`/`envOverride` calls below can throw or - * otherwise cause a downstream rejection on a malformed value belong in this set so a - * matched remote block's value wins instead of aborting on an unrelated bad env var. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["mfa"] { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - return { - totp: { - enroll_enabled: remoteWins("auth.mfa.totp.enroll_enabled") - ? mfa.totp.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", - mfa.totp.enroll_enabled, - "auth.mfa.totp.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.totp.verify_enabled") - ? mfa.totp.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", - mfa.totp.verify_enabled, - "auth.mfa.totp.verify_enabled", - projectEnvValues, - ), - }, - phone: { - enroll_enabled: remoteWins("auth.mfa.phone.enroll_enabled") - ? mfa.phone.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", - mfa.phone.enroll_enabled, - "auth.mfa.phone.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.phone.verify_enabled") - ? mfa.phone.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", - mfa.phone.verify_enabled, - "auth.mfa.phone.verify_enabled", - projectEnvValues, - ), - otp_length: remoteWins("auth.mfa.phone.otp_length") - ? mfa.phone.otp_length - : envOverrideUint( - "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", - "auth.mfa.phone.otp_length", - mfa.phone.otp_length, - projectEnvValues, - ), - template: remoteWins("auth.mfa.phone.template") - ? mfa.phone.template - : (envOverride("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", mfa.phone.template, projectEnvValues) ?? - mfa.phone.template), - max_frequency: remoteWins("auth.mfa.phone.max_frequency") - ? mfa.phone.max_frequency - : (envOverride( - "SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", - mfa.phone.max_frequency, - projectEnvValues, - ) ?? mfa.phone.max_frequency), - }, - web_authn: { - enroll_enabled: remoteWins("auth.mfa.web_authn.enroll_enabled") - ? mfa.web_authn.enroll_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", - mfa.web_authn.enroll_enabled, - "auth.mfa.web_authn.enroll_enabled", - projectEnvValues, - ), - verify_enabled: remoteWins("auth.mfa.web_authn.verify_enabled") - ? mfa.web_authn.verify_enabled - : envOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", - mfa.web_authn.verify_enabled, - "auth.mfa.web_authn.verify_enabled", - projectEnvValues, - ), - }, - max_enrolled_factors: remoteWins("auth.mfa.max_enrolled_factors") - ? mfa.max_enrolled_factors - : envOverrideUint( - "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", - "auth.mfa.max_enrolled_factors", - mfa.max_enrolled_factors, - projectEnvValues, - ), - }; +/** The effective `auth.mfa`. */ +export function resolveAuthMfa(mfa: CliConfig["auth"]["mfa"]): CliConfig["auth"]["mfa"] { + return mfa; } -/** - * Resolves `auth.rate_limit.*`, all plain `uint`s with no presence gate: every - * `SUPABASE_AUTH_RATE_LIMIT_*` override applies unconditionally. - */ +/** The effective `auth.rate_limit`. */ export function resolveGotrueRateLimit( rateLimit: CliConfig["auth"]["rate_limit"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["rate_limit"] { - return { - anonymous_users: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", - "auth.rate_limit.anonymous_users", - rateLimit.anonymous_users, - projectEnvValues, - ), - token_refresh: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_REFRESH", - "auth.rate_limit.token_refresh", - rateLimit.token_refresh, - projectEnvValues, - ), - sign_in_sign_ups: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SIGN_IN_SIGN_UPS", - "auth.rate_limit.sign_in_sign_ups", - rateLimit.sign_in_sign_ups, - projectEnvValues, - ), - token_verifications: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_VERIFICATIONS", - "auth.rate_limit.token_verifications", - rateLimit.token_verifications, - projectEnvValues, - ), - email_sent: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT", - "auth.rate_limit.email_sent", - rateLimit.email_sent, - projectEnvValues, - ), - sms_sent: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", - "auth.rate_limit.sms_sent", - rateLimit.sms_sent, - projectEnvValues, - ), - web3: envOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_WEB3", - "auth.rate_limit.web3", - rateLimit.web3, - projectEnvValues, - ), - }; + return rateLimit; } -/** - * Resolves `auth.sessions.{timebox,inactivity_timeout}`. An env override can introduce a - * value even when `[auth.sessions]` was never in config.toml, since `config.auth.sessions` - * can be `undefined` here unlike other always-bound sections. - */ +/** The effective `auth.sessions`. */ export function resolveGotrueSessions( sessions: CliConfig["auth"]["sessions"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["sessions"] { - const timebox = envOverride( - "SUPABASE_AUTH_SESSIONS_TIMEBOX", - sessions?.timebox, - projectEnvValues, - ); - const inactivityTimeout = envOverride( - "SUPABASE_AUTH_SESSIONS_INACTIVITY_TIMEOUT", - sessions?.inactivity_timeout, - projectEnvValues, - ); - if (timebox === undefined && inactivityTimeout === undefined) return sessions; - return { timebox, inactivity_timeout: inactivityTimeout }; + return sessions; } /** - * Resolves `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields at - * all: presence and every field come from the raw TOML document. An absent section is never - * synthesized from an env override alone. + * Reads `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields: presence + * and every field come from the effective document. An absent section stays absent. */ export function resolveGotruePasskeyWebauthn( document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, ): { readonly passkeyEnabled: boolean | undefined; readonly webauthn: @@ -1625,493 +536,86 @@ export function resolveGotruePasskeyWebauthn( const webauthnDoc = asRecord(authDoc?.["webauthn"]); const passkeyEnabled = passkeyDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : undefined; - const rpOriginsOverride = - webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + ? rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled") : undefined; const webauthn = webauthnDoc !== undefined ? { - rpId: - envOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_ID", - typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : "", - projectEnvValues, - ) ?? "", - rpDisplayName: - envOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", - typeof webauthnDoc["rp_display_name"] === "string" - ? webauthnDoc["rp_display_name"] - : "", - projectEnvValues, - ) ?? "", - // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. - rpOrigins: (() => { - if (rpOriginsOverride !== undefined) return strToArr(rpOriginsOverride); - const raw = webauthnDoc["rp_origins"]; - if (Array.isArray(raw)) { - return raw.filter((item): item is string => typeof item === "string"); - } - return typeof raw === "string" ? strToArr(raw) : []; - })(), + rpId: asString(webauthnDoc["rp_id"]) ?? "", + rpDisplayName: asString(webauthnDoc["rp_display_name"]) ?? "", + rpOrigins: rawOrigins(webauthnDoc["rp_origins"]) ?? [], } : undefined; return { passkeyEnabled, webauthn }; } -/** Resolves `auth.web3.*.enabled`, both value-typed with no presence gate — overrides always apply. */ -export function resolveGotrueWeb3( - web3: CliConfig["auth"]["web3"], - projectEnvValues: Readonly> | undefined, -): CliConfig["auth"]["web3"] { - return { - solana: { - enabled: envOverrideBool( - "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", - web3.solana.enabled, - "auth.web3.solana.enabled", - projectEnvValues, - ), - }, - ethereum: { - enabled: envOverrideBool( - "SUPABASE_AUTH_WEB3_ETHEREUM_ENABLED", - web3.ethereum.enabled, - "auth.web3.ethereum.enabled", - projectEnvValues, - ), - }, - }; +/** A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. */ +function rawOrigins(raw: unknown): Array | undefined { + if (Array.isArray(raw)) return raw.filter((item): item is string => typeof item === "string"); + return typeof raw === "string" ? strToArr(raw) : undefined; +} + +/** The effective `auth.web3`. */ +export function resolveGotrueWeb3(web3: CliConfig["auth"]["web3"]): CliConfig["auth"]["web3"] { + return web3; } -/** Resolves `auth.oauth_server.*`, value-typed with no presence gate — overrides always apply. */ +/** The effective `auth.oauth_server`. */ export function resolveGotrueOAuthServer( oauthServer: CliConfig["auth"]["oauth_server"], - projectEnvValues: Readonly> | undefined, ): CliConfig["auth"]["oauth_server"] { - return { - enabled: envOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", - oauthServer.enabled, - "auth.oauth_server.enabled", - projectEnvValues, - ), - authorization_url_path: - envOverride( - "SUPABASE_AUTH_OAUTH_SERVER_AUTHORIZATION_URL_PATH", - oauthServer.authorization_url_path, - projectEnvValues, - ) ?? oauthServer.authorization_url_path, - allow_dynamic_registration: envOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", - oauthServer.allow_dynamic_registration, - "auth.oauth_server.allow_dynamic_registration", - projectEnvValues, - ), - }; + return oauthServer; } -/** - * Resolves enabled `auth.third_party.` entries in a fixed order, forwarding only - * the enabled ones. `remoteOverrideKeys` lets a matched remote block's value win over a - * conflicting `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED`, since `envOverrideBool` would otherwise - * throw and abort the whole call. - */ +/** Lists enabled `auth.third_party.` entries in a fixed order, with their required fields. */ export function resolveThirdPartyProviders( thirdParty: CliConfig["auth"]["third_party"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const resolved: Array = []; - if ( - remoteWins("auth.third_party.firebase.enabled") - ? thirdParty.firebase.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - thirdParty.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "firebase", - requiredField: - (remoteWins("auth.third_party.firebase.project_id") - ? thirdParty.firebase.project_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - thirdParty.firebase.project_id, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.firebase.enabled) { + resolved.push({ provider: "firebase", requiredField: thirdParty.firebase.project_id ?? "" }); } - if ( - remoteWins("auth.third_party.auth0.enabled") - ? thirdParty.auth0.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - thirdParty.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "auth0", - requiredField: - (remoteWins("auth.third_party.auth0.tenant") - ? thirdParty.auth0.tenant - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - thirdParty.auth0.tenant, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.auth0.enabled) { + resolved.push({ provider: "auth0", requiredField: thirdParty.auth0.tenant ?? "" }); } - if ( - remoteWins("auth.third_party.aws_cognito.enabled") - ? thirdParty.aws_cognito.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - thirdParty.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ) - ) { + if (thirdParty.aws_cognito.enabled) { resolved.push({ provider: "cognito", - requiredField: - (remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? thirdParty.aws_cognito.user_pool_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - thirdParty.aws_cognito.user_pool_id, - projectEnvValues, - )) ?? "", - cognitoUserPoolRegion: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? thirdParty.aws_cognito.user_pool_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - thirdParty.aws_cognito.user_pool_region, - projectEnvValues, - ), + requiredField: thirdParty.aws_cognito.user_pool_id ?? "", + cognitoUserPoolRegion: thirdParty.aws_cognito.user_pool_region, }); } - if ( - remoteWins("auth.third_party.clerk.enabled") - ? thirdParty.clerk.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - thirdParty.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "clerk", - requiredField: - (remoteWins("auth.third_party.clerk.domain") - ? thirdParty.clerk.domain - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - thirdParty.clerk.domain, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.clerk.enabled) { + resolved.push({ provider: "clerk", requiredField: thirdParty.clerk.domain ?? "" }); } - if ( - remoteWins("auth.third_party.workos.enabled") - ? thirdParty.workos.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - thirdParty.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ) - ) { - resolved.push({ - provider: "workos", - requiredField: - (remoteWins("auth.third_party.workos.issuer_url") - ? thirdParty.workos.issuer_url - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - thirdParty.workos.issuer_url, - projectEnvValues, - )) ?? "", - }); + if (thirdParty.workos.enabled) { + resolved.push({ provider: "workos", requiredField: thirdParty.workos.issuer_url ?? "" }); } return resolved; } -/** Fixed SMS provider priority — validation stops at the first enabled provider. */ -const SMS_PROVIDER_ORDER = [ - "twilio", - "twilio_verify", - "messagebird", - "textlocal", - "vonage", -] as const; - /** - * Resolves `auth.sms`'s full field set so `SUPABASE_AUTH_SMS_*` overrides reach GoTrue's env, - * not just validation. Presence-gated per provider except `twilio`, whose default template - * always includes an uncommented `[auth.sms.twilio]` table; the 4 top-level scalars have no - * gate for the same reason. `test_otp` (a map) is left unresolved: it has no env var. + * The effective `auth.sms`. Phone signup is never enabled when no provider is configured to + * deliver an OTP, and twilio's two required ids default to `""`. */ -export function resolveAuthSms( - authDocument: Readonly> | undefined, - sms: CliConfig["auth"]["sms"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed - * value belong in this set so a matched remote block's value wins instead of aborting on an - * unrelated bad env var. Reachable from the `db diff --linked`/`db pull` shadow path via - * `validateAuthSmsProviders`, not just this function's direct callers. - */ - remoteOverrideKeys: ReadonlySet = new Set(), -): CliConfig["auth"]["sms"] { - const smsDoc = asRecord(authDocument?.["sms"]); - const remoteWins = makeRemoteWins(remoteOverrideKeys); - - function providerPresent(providerName: (typeof SMS_PROVIDER_ORDER)[number]): boolean { - // `twilio` is always considered present — see this function's doc comment. - if (providerName === "twilio") return true; - return smsDoc !== undefined && asRecord(smsDoc[providerName]) !== undefined; - } - - function resolveEnabled( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - configured: boolean, - ): boolean { - if (remoteWins(`auth.sms.${providerName}.enabled`)) return configured; - if (!providerPresent(providerName)) return configured; - return envOverrideBool( - `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_ENABLED`, - configured, - `auth.sms.${providerName}.enabled`, - projectEnvValues, - ); - } - - // `remoteOverrideKey` is explicit (not reconstructed from `providerName`/`field`) since - // `field`'s shape differs per provider, and not every combination is a real config key. - function resolveField( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - field: string, - remoteOverrideKey: RemoteOverridableKey, - configured: string | undefined, - ): string | undefined { - if (remoteWins(remoteOverrideKey)) return configured; - if (!providerPresent(providerName)) return configured; - return envOverride( - `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_${field.toUpperCase()}`, - configured, - projectEnvValues, - ); - } - - /** Resolves a provider's Secret-typed field, gated the same way `auth.email.smtp.pass` is. */ - function resolveSecretField( - providerName: (typeof SMS_PROVIDER_ORDER)[number], - field: string, - remoteOverrideKey: RemoteOverridableKey, - configured: string | undefined, - ): string | undefined { - return remoteWins(remoteOverrideKey) - ? decryptAuthSecret(configured, projectEnvValues) - : decryptAuthSecret( - resolveField(providerName, field, remoteOverrideKey, configured), - projectEnvValues, - ); - } - - const twilioEnabled = resolveEnabled("twilio", sms.twilio.enabled); - const twilioVerifyEnabled = resolveEnabled("twilio_verify", sms.twilio_verify.enabled); - const messagebirdEnabled = resolveEnabled("messagebird", sms.messagebird.enabled); - const textlocalEnabled = resolveEnabled("textlocal", sms.textlocal.enabled); - const vonageEnabled = resolveEnabled("vonage", sms.vonage.enabled); +export function resolveAuthSms(sms: CliConfig["auth"]["sms"]): CliConfig["auth"]["sms"] { const anyProviderEnabled = - twilioEnabled || twilioVerifyEnabled || messagebirdEnabled || textlocalEnabled || vonageEnabled; - const enableSignupConfigured = remoteWins("auth.sms.enable_signup") - ? sms.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ); - + sms.twilio.enabled || + sms.twilio_verify.enabled || + sms.messagebird.enabled || + sms.textlocal.enabled || + sms.vonage.enabled; return { ...sms, - // Phone signup is never enabled when no provider is configured to deliver an OTP. - enable_signup: anyProviderEnabled ? enableSignupConfigured : false, - enable_confirmations: remoteWins("auth.sms.enable_confirmations") - ? sms.enable_confirmations - : envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", - sms.enable_confirmations, - "auth.sms.enable_confirmations", - projectEnvValues, - ), - template: remoteWins("auth.sms.template") - ? sms.template - : (envOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? sms.template), - max_frequency: remoteWins("auth.sms.max_frequency") - ? sms.max_frequency - : (envOverride("SUPABASE_AUTH_SMS_MAX_FREQUENCY", sms.max_frequency, projectEnvValues) ?? - sms.max_frequency), + enable_signup: anyProviderEnabled ? sms.enable_signup : false, twilio: { - enabled: twilioEnabled, - account_sid: - resolveField( - "twilio", - "account_sid", - "auth.sms.twilio.account_sid", - sms.twilio.account_sid, - ) ?? "", - message_service_sid: - resolveField( - "twilio", - "message_service_sid", - "auth.sms.twilio.message_service_sid", - sms.twilio.message_service_sid, - ) ?? "", - auth_token: resolveSecretField( - "twilio", - "auth_token", - "auth.sms.twilio.auth_token", - sms.twilio.auth_token, - ), - }, - twilio_verify: { - enabled: twilioVerifyEnabled, - account_sid: resolveField( - "twilio_verify", - "account_sid", - "auth.sms.twilio_verify.account_sid", - sms.twilio_verify.account_sid, - ), - message_service_sid: resolveField( - "twilio_verify", - "message_service_sid", - "auth.sms.twilio_verify.message_service_sid", - sms.twilio_verify.message_service_sid, - ), - auth_token: resolveSecretField( - "twilio_verify", - "auth_token", - "auth.sms.twilio_verify.auth_token", - sms.twilio_verify.auth_token, - ), - }, - messagebird: { - enabled: messagebirdEnabled, - originator: resolveField( - "messagebird", - "originator", - "auth.sms.messagebird.originator", - sms.messagebird.originator, - ), - access_key: resolveSecretField( - "messagebird", - "access_key", - "auth.sms.messagebird.access_key", - sms.messagebird.access_key, - ), - }, - textlocal: { - enabled: textlocalEnabled, - sender: resolveField( - "textlocal", - "sender", - "auth.sms.textlocal.sender", - sms.textlocal.sender, - ), - api_key: resolveSecretField( - "textlocal", - "api_key", - "auth.sms.textlocal.api_key", - sms.textlocal.api_key, - ), - }, - vonage: { - enabled: vonageEnabled, - from: resolveField("vonage", "from", "auth.sms.vonage.from", sms.vonage.from), - api_key: resolveField("vonage", "api_key", "auth.sms.vonage.api_key", sms.vonage.api_key), - api_secret: resolveSecretField( - "vonage", - "api_secret", - "auth.sms.vonage.api_secret", - sms.vonage.api_secret, - ), + ...sms.twilio, + account_sid: sms.twilio.account_sid ?? "", + message_service_sid: sms.twilio.message_service_sid ?? "", }, }; } -/** - * Validates only the first enabled provider in {@link SMS_PROVIDER_ORDER}; a later - * enabled-but-incomplete provider is never checked. Runs against {@link resolveAuthSms}'s - * env-override-aware result. - */ -function validateAuthSmsProviders( - authDocument: Record | undefined, - sms: CliConfig["auth"]["sms"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); - - function requireField(provider: string, field: string, value: string | undefined): void { - if (value === undefined || value.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.sms.${provider}.${field}`, - ); - } - } - - if (resolved.twilio.enabled) { - requireField("twilio", "account_sid", resolved.twilio.account_sid); - requireField("twilio", "message_service_sid", resolved.twilio.message_service_sid); - requireField("twilio", "auth_token", resolved.twilio.auth_token); - return; - } - if (resolved.twilio_verify.enabled) { - requireField("twilio_verify", "account_sid", resolved.twilio_verify.account_sid); - requireField( - "twilio_verify", - "message_service_sid", - resolved.twilio_verify.message_service_sid, - ); - requireField("twilio_verify", "auth_token", resolved.twilio_verify.auth_token); - return; - } - if (resolved.messagebird.enabled) { - requireField("messagebird", "originator", resolved.messagebird.originator); - requireField("messagebird", "access_key", resolved.messagebird.access_key); - return; - } - if (resolved.textlocal.enabled) { - requireField("textlocal", "sender", resolved.textlocal.sender); - requireField("textlocal", "api_key", resolved.textlocal.api_key); - return; - } - if (resolved.vonage.enabled) { - requireField("vonage", "from", resolved.vonage.from); - requireField("vonage", "api_key", resolved.vonage.api_key); - requireField("vonage", "api_secret", resolved.vonage.api_secret); - return; - } -} - /** Deleted external providers, warned on if still enabled, never validated. */ const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); @@ -2127,7 +631,7 @@ export interface ResolvedAuthExternalProvider { } /** - * Weakly coerces an unmodeled raw-document value (no `@supabase/config` schema, e.g. custom + * Weakly coerces an unmodeled document value (no `@supabase/config` schema, e.g. custom * `auth.external` providers, `auth.passkey`/`auth.webauthn`) to a bool, since an `env(VAR)` * substitution there skips normal type coercion and leaves a literal `"true"`/`"false"` * string. A number coerces via truthiness (`!= 0`); an unparsable string or any other type @@ -2137,173 +641,70 @@ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boole if (value === undefined) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; - if (typeof value === "string") { - const parsed = parseBoolLiteral(value); - if (parsed === undefined) { - throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); - } - return parsed; + const raw = typeof value === "string" ? value : String(value); + const parsed = typeof value === "string" ? parseBoolLiteral(value) : undefined; + if (parsed === undefined) { + throw new CliConfigValueError({ + path: dottedFieldPath, + tier: "config", + message: `Invalid config for ${dottedFieldPath}: "${raw}" is not a valid boolean`, + }); } - throw new InvalidBoolEnvOverrideError(dottedFieldPath, String(value)); + return parsed; } -/** - * Comma-splits a string into an array (empty string → `[]`), the same rule config decode - * applies to a raw or `env(VAR)`-resolved value destined for an unmodeled `[]string` field - * (e.g. `auth.webauthn.rp_origins`). - */ -export function strToArr(value: string): Array { +/** Comma-splits a string, with the empty string as `[]`. */ +function strToArr(value: string): Array { return value.length === 0 ? [] : value.split(","); } /** - * Resolves `auth.external.` overrides, iterating the raw document's provider names + * Resolves `auth.external.` entries, iterating the effective document's provider names * (not just the schema's fixed ~19) since custom providers decode with no schema at all. * `apple` is always included, since the default config.toml template registers it - * uncommented. Hoisted so both {@link validateAuthExternalProviders} and `start.handler.ts`'s - * GoTrue env resolution share this same result. + * uncommented. */ export function resolveAuthExternalProviders( authDocument: Readonly> | undefined, external: CliConfig["auth"]["external"], - projectEnvValues: Readonly> | undefined, - /** - * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed - * value belong in this set so a matched remote block's value wins instead of aborting on an - * unrelated bad env var. Provider names are dynamic, so these keys are tracked via - * `applyRemoteOverride` rather than a fixed `ENV_OVERRIDABLE_KEYS` entry. - */ - remoteOverrideKeys: ReadonlySet = new Set(), ): Record { - const remoteWins = makeRemoteWins(remoteOverrideKeys); const externalDoc = asRecord(authDocument?.["external"]); const result: Record = {}; const decodedProviders = new Map(Object.entries(external)); - // Iterates the raw document's keys (not `Object.entries(external)`) to catch custom - // provider names; `apple` is unioned in since the default template always registers it. const providerNames = new Set([...Object.keys(externalDoc ?? {}), "apple"]); for (const name of providerNames) { if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; - const envPrefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; const provider = decodedProviders.get(name); const rawProvider = provider === undefined ? asRecord(externalDoc?.[name]) : undefined; if (provider === undefined && rawProvider === undefined) continue; - const configuredEnabled = - provider?.enabled ?? - rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`); - const configuredClientId = - provider?.client_id ?? - (typeof rawProvider?.["client_id"] === "string" ? rawProvider["client_id"] : undefined); - const configuredSecret = - provider?.secret ?? - (typeof rawProvider?.["secret"] === "string" ? rawProvider["secret"] : undefined); - const configuredUrl = - provider?.url ?? (typeof rawProvider?.["url"] === "string" ? rawProvider["url"] : undefined); - const configuredRedirectUri = - provider?.redirect_uri ?? - (typeof rawProvider?.["redirect_uri"] === "string" ? rawProvider["redirect_uri"] : undefined); - const configuredSkipNonceCheck = - provider?.skip_nonce_check ?? - rawUnmodeledBool(rawProvider?.["skip_nonce_check"], `auth.external.${name}.skip_nonce_check`); - const configuredEmailOptional = - provider?.email_optional ?? - rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`); - result[name] = { - enabled: remoteWins(`auth.external.${name}.enabled`) - ? configuredEnabled - : envOverrideBool( - `${envPrefix}_ENABLED`, - configuredEnabled, - `auth.external.${name}.enabled`, - projectEnvValues, - ), - clientId: - (remoteWins(`auth.external.${name}.client_id`) - ? configuredClientId - : envOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? "", - secret: remoteWins(`auth.external.${name}.secret`) - ? decryptAuthSecret(configuredSecret, projectEnvValues) - : decryptAuthSecret( - envOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), - projectEnvValues, - ), - url: - (remoteWins(`auth.external.${name}.url`) - ? configuredUrl - : envOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", - redirectUri: remoteWins(`auth.external.${name}.redirect_uri`) - ? configuredRedirectUri - : envOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), - skipNonceCheck: remoteWins(`auth.external.${name}.skip_nonce_check`) - ? configuredSkipNonceCheck - : envOverrideBool( - `${envPrefix}_SKIP_NONCE_CHECK`, - configuredSkipNonceCheck, - `auth.external.${name}.skip_nonce_check`, - projectEnvValues, - ), - emailOptional: remoteWins(`auth.external.${name}.email_optional`) - ? configuredEmailOptional - : envOverrideBool( - `${envPrefix}_EMAIL_OPTIONAL`, - configuredEmailOptional, - `auth.external.${name}.email_optional`, - projectEnvValues, - ), + enabled: + provider?.enabled ?? + rawUnmodeledBool(rawProvider?.["enabled"], `auth.external.${name}.enabled`), + clientId: provider?.client_id ?? asString(rawProvider?.["client_id"]) ?? "", + secret: provider?.secret ?? asString(rawProvider?.["secret"]), + url: provider?.url ?? asString(rawProvider?.["url"]) ?? "", + redirectUri: provider?.redirect_uri ?? asString(rawProvider?.["redirect_uri"]), + skipNonceCheck: + provider?.skip_nonce_check ?? + rawUnmodeledBool( + rawProvider?.["skip_nonce_check"], + `auth.external.${name}.skip_nonce_check`, + ), + emailOptional: + provider?.email_optional ?? + rawUnmodeledBool(rawProvider?.["email_optional"], `auth.external.${name}.email_optional`), }; } return result; } /** - * Validates required fields for every enabled `auth.external.` provider, including - * custom names `@supabase/config`'s schema silently drops at decode time. Runs against - * {@link resolveAuthExternalProviders}'s env-override-aware result, since the schema's own - * `requiredWhenEnabled` check only sees the pre-override, known-providers-only value. - */ -function validateAuthExternalProviders( - authDocument: Record | undefined, - external: CliConfig["auth"]["external"], - projectEnvValues: Readonly> | undefined, - remoteOverrideKeys: ReadonlySet = new Set(), -): void { - const resolved = resolveAuthExternalProviders( - authDocument, - external, - projectEnvValues, - remoteOverrideKeys, - ); - for (const [name, provider] of Object.entries(resolved)) { - if (!provider.enabled) continue; - if (provider.clientId.length === 0) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.client_id`, - ); - } - if ( - name !== "apple" && - name !== "google" && - (provider.secret === undefined || provider.secret.length === 0) - ) { - throw new ConfigValidateError( - `Missing required field in config: auth.external.${name}.secret`, - ); - } - } -} - -/** - * @throws when `project_id` (post-override, post-workdir-basename-fallback) is an explicit - * empty string. Checked first: the sanitized workdir basename is merged in as a default - * before `config.toml`, so `project_id` is never empty by the time validation runs — a - * workdir whose basename sanitizes to `""` fails even with no `project_id` key at all. + * @throws when `project_id` is an explicit empty string. Checked first: the sanitized workdir + * basename is the default, so a workdir whose basename sanitizes to `""` fails even with no + * `project_id` key at all. * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} 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}. * @throws when `auth.signing_keys_path` is set, auth is enabled, and the file is missing, @@ -2311,8 +712,6 @@ function validateAuthExternalProviders( * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricLocalJwt}. * @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 {InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` doesn't - * parse as one of its accepted values. * @throws {ConfigValidateError} for every other validation branch, deferred to a single call * to {@link validateResolvedConfig} at the end of this function. */ @@ -2320,334 +719,78 @@ export function resolveLocalConfigValues( config: CliConfig, hostname: string, workdir: string, - projectEnvValues?: Readonly>, /** - * The raw, pre-schema-default TOML document `config` was decoded from, letting checks that - * hinge on section presence (not the always-defaulted decoded value) inspect the file - * directly. `undefined` callers simply skip those checks. + * The effective document `config` was decoded from, for checks that hinge on section presence + * (not the always-defaulted decoded value). `undefined` callers skip those checks. */ document?: Readonly>, - /** - * Config keys a matched `[remotes.]` block set at override tier. Every field whose - * `envOverride*` call (or a downstream {@link validateResolvedConfig} check) can throw on a - * malformed value belongs in this set — this function returns its whole object or throws, so - * an unconditional throw anywhere aborts the whole call, even for a field not part of the - * returned `LocalConfigValues`. Defaults to empty for callers with no `[remotes.]` block. - */ - remoteOverrideKeys: ReadonlySet = new Set(), - /** - * Default `project_id` supplied by `--project-ref`/the linked project ref before the - * basename fallback applies. `undefined` for `status`/`stop`, which have no such flag. - */ - projectIdFallback?: string, ): LocalConfigValues { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - // Checked first, before every other field: `config.project_id` is `undefined` only when the - // key is absent, in which case the sanitized workdir basename (or - // `projectIdFallback`) applies instead of a file value. - // - // Not gated by `remoteWins("project_id")` like the fields below: `envOverride` never turns - // an already non-empty remote-merged value empty or vice versa, so gating here couldn't - // change the accept/reject outcome. - const resolvedProjectId = envOverride( - "SUPABASE_PROJECT_ID", - config.project_id ?? - (projectIdFallback !== undefined && projectIdFallback.length > 0 - ? projectIdFallback - : sanitizeProjectId(basename(workdir))), - projectEnvValues, - ); + const resolvedProjectId = config.project_id ?? ""; - const apiTlsEnabled = remoteWins("api.tls.enabled") - ? config.api.tls.enabled - : envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ); - // TLS cert/key validation only runs while `api.enabled`, so this gates on the post-override - // value, not raw `config.api.enabled`. - const apiEnabled = remoteWins("api.enabled") - ? config.api.enabled - : envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues); - const apiTlsCertPath = remoteWins("api.tls.cert_path") - ? config.api.tls.cert_path - : envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); - const apiTlsKeyPath = remoteWins("api.tls.key_path") - ? config.api.tls.key_path - : envOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); + const apiTlsEnabled = config.api.tls.enabled; + const apiEnabled = config.api.enabled; + const apiTlsCertPath = config.api.tls.cert_path; + const apiTlsKeyPath = config.api.tls.key_path; if (apiEnabled && apiTlsEnabled) { readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } - // `api.port === 0` is rejected only when `api.enabled`, unlike `db.port` below. Resolved once - // so the check and the URL derivation below share the same overridden value. - const apiPort = remoteWins("api.port") - ? config.api.port - : envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); + const apiPort = config.api.port; const apiExternalUrl = resolveApiExternalUrl( { - external_url: remoteWins("api.external_url") - ? config.api.external_url - : envOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), + external_url: config.api.external_url, port: apiPort, tls: { enabled: apiTlsEnabled }, }, hostname, ); - // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no `enabled` gate — - // it's unconditionally required, and a decoded `0` fails validation. - const dbPort = remoteWins("db.port") - ? config.db.port - : envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); - // Validate-only: the shadow's own resolved `majorVersion` comes from - // `resolveDbBootstrapConfig`, which gates it separately. - const majorVersion = remoteWins("db.major_version") - ? config.db.major_version - : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // Validate-only: the actual resolved settings `start` needs are recomputed at their own call - // site. - resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); - // Validate-only: `start` doesn't otherwise consume `db.network_restrictions.enabled` (only - // `config push` does). - if (!remoteWins("db.network_restrictions.enabled")) { - envOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ); - } - // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the raw - // pre-schema document. The resolved, decrypted-if-`encrypted:` value is written verbatim into - // `/etc/postgresql-custom/pgsodium_root.key` on every start. - const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; - if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { + const dbPort = config.db.port; + const majorVersion = config.db.major_version; + // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the document. + // The resolved value is written verbatim into `/etc/postgresql-custom/pgsodium_root.key`. + const rawRootKey = asRecord(document?.["db"])?.["root_key"]; + if (rawRootKey !== undefined && typeof rawRootKey !== "string") { throw new ConfigValidateError("failed to parse config:\ndb.root_key: expected a table"); } - const rawRootKey = remoteWins("db.root_key") - ? rawRootKeyValue - : envOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); const rootKey = - rawRootKey === undefined || rawRootKey.length === 0 - ? POSTGRES_DEFAULT_ROOT_KEY - : (decryptAuthSecret(rawRootKey, projectEnvValues) ?? POSTGRES_DEFAULT_ROOT_KEY); + rawRootKey === undefined || rawRootKey.length === 0 ? POSTGRES_DEFAULT_ROOT_KEY : rawRootKey; const storageBucketNames = config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; - // `studio.port === 0` is rejected only when `studio.enabled`, same enabled-gated pattern as - // `api.port` above. - const studioEnabled = remoteWins("studio.enabled") - ? config.studio.enabled - : envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const studioPort = remoteWins("studio.port") - ? config.studio.port - : envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues); - // `envOverride` itself never throws, but `studio.api_url` feeds `validateResolvedConfig`'s - // URL-parse check below, which does throw on a malformed URL, so this is gated the same way - // as `studio.enabled`/`studio.port` above. - const studioApiUrl = remoteWins("studio.api_url") - ? config.studio.api_url - : (envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url); - // `local_smtp.port === 0` is rejected only when `local_smtp.enabled`; the deprecated - // `inbucket.enabled` alias is the same underlying flag, not a second one. - const mailpitEnabled = remoteWins("local_smtp.enabled") - ? config.local_smtp.enabled - : envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const mailpitPort = remoteWins("local_smtp.port") - ? config.local_smtp.port - : envOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ); - const jwtSecret = resolveJwtSecret( - decryptAuthSecret( - remoteWins("auth.jwt_secret") - ? config.auth.jwt_secret - : envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const studioEnabled = config.studio.enabled; + const studioPort = config.studio.port; + const studioApiUrl = config.studio.api_url; + const mailpitEnabled = config.local_smtp.enabled; + const mailpitPort = config.local_smtp.port; + const jwtSecret = resolveJwtSecret(config.auth.jwt_secret); + const signingKeysPath = config.auth.signing_keys_path; // The signing-keys file read only runs when auth is 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 run unconditionally either way. - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - // `@supabase/config`'s schema only defaults `site_url` when the key is absent, so an - // explicit `site_url = ""` decodes as `""` with no schema-level error, same gap as - // `db.port === 0` above. - const siteUrl = remoteWins("auth.site_url") - ? config.auth.site_url - : (envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? - config.auth.site_url); - const jwtIssuer = remoteWins("auth.jwt_issuer") - ? config.auth.jwt_issuer - : envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); - const jwtExpiry = remoteWins("auth.jwt_expiry") - ? config.auth.jwt_expiry - : envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ); - const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") - ? undefined - : envOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); - const additionalRedirectUrls = - additionalRedirectUrlsOverride !== undefined - ? additionalRedirectUrlsOverride.split(",") - : config.auth.additional_redirect_urls; - const enableSignup = remoteWins("auth.enable_signup") - ? config.auth.enable_signup - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ); - const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") - ? config.auth.enable_anonymous_sign_ins - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ); - const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") - ? config.auth.enable_refresh_token_rotation - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ); - const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") - ? config.auth.refresh_token_reuse_interval - : envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ); - const enableManualLinking = remoteWins("auth.enable_manual_linking") - ? config.auth.enable_manual_linking - : envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ); - const minimumPasswordLength = remoteWins("auth.minimum_password_length") - ? config.auth.minimum_password_length - : envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ); - const passwordRequirements = remoteWins("auth.password_requirements") - ? config.auth.password_requirements - : envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues); + const authEnabled = config.auth.enabled; + const siteUrl = config.auth.site_url; const authDocument = asRecord(document?.["auth"]); - const captchaInput = resolveAuthCaptcha( - authDocument, - config.auth.captcha, - projectEnvValues, - remoteOverrideKeys, - ); - // Reuses {@link resolveConfiguredSigningKeys}, which already gates the file read on - // `authEnabled` internally, rather than duplicating that gate here: a disabled-auth config - // with a configured path must still sign asymmetrically with the default key, not fall back - // to symmetric HS256. + const captchaInput = resolveAuthCaptcha(config.auth.captcha); + // A disabled-auth config with a configured path must still sign asymmetrically with the + // default key, not fall back to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (resolveConfiguredSigningKeys(config, workdir, projectEnvValues, remoteOverrideKeys) ?? [ - DEFAULT_SIGNING_KEY, - ])[0] + ? (resolveConfiguredSigningKeys(config, workdir) ?? [DEFAULT_SIGNING_KEY])[0] : undefined; // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party // validation needs; the checks themselves run once, later, in the single // `validateResolvedConfig` call below (sms/external run separately after it). let authInput: AuthInput | undefined; if (authEnabled) { - // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all, so they're - // read from the raw, post-`env()`-interpolation TOML document instead of the decoded - // `CliConfig`. `authDocument` is `undefined` when a caller hasn't threaded `document` - // through yet, in which case these presence-based checks are simply skipped. const passkeyDoc = asRecord(authDocument?.["passkey"]); const webauthnDoc = asRecord(authDocument?.["webauthn"]); - // Gated on the raw section already being present: only keys already present in the merged - // config are env-bindable, so an absent `[auth.passkey]`/`[auth.webauthn]` section is never - // synthesized from an env override alone. - const passkeyEnabled = remoteWins("auth.passkey.enabled") - ? rawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") - : passkeyDoc !== undefined - ? envOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : false; - const configuredRpId = - typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; - const rpId = remoteWins("auth.webauthn.rp_id") - ? configuredRpId - : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) - : undefined; - const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") - ? undefined - : webauthnDoc !== undefined - ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) - : undefined; - // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. - const rawRpOrigins = webauthnDoc?.["rp_origins"]; - const rpOrigins = - rpOriginsOverride !== undefined - ? strToArr(rpOriginsOverride) - : Array.isArray(rawRpOrigins) - ? rawRpOrigins - : typeof rawRpOrigins === "string" - ? strToArr(rawRpOrigins) - : undefined; + const passkeyEnabled = + passkeyDoc !== undefined && rawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"); + const rpId = asString(webauthnDoc?.["rp_id"]); + const rpOrigins = rawOrigins(webauthnDoc?.["rp_origins"]); const passkey: PasskeyInput | undefined = passkeyEnabled ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } : undefined; - // Only enabled hooks are forwarded, in {@link HOOK_TYPE_ORDER}. - const resolvedHooks = resolveAuthHooks( - authDocument, - config.auth.hook, - projectEnvValues, - remoteOverrideKeys, - ); + const resolvedHooks = resolveAuthHooks(config.auth.hook); const hooks: Array = HOOK_TYPE_ORDER.filter( (hookType) => resolvedHooks[HOOK_TYPE_TO_CAMEL[hookType]].enabled, ).map((hookType) => { @@ -2655,7 +798,7 @@ export function resolveLocalConfigValues( return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; }); - const resolvedMfa = resolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); + const resolvedMfa = config.auth.mfa; const mfa: ReadonlyArray = [ { label: "totp", @@ -2674,12 +817,9 @@ export function resolveLocalConfigValues( }, ]; - readAuthEmailTemplateContent( - resolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), - workdir, - ); + readAuthEmailTemplateContent(resolveAuthEmail(config.auth.email, authDocument), workdir); - const resolvedSmtp = resolveAuthEmailSmtp(authDocument, projectEnvValues, remoteOverrideKeys); + const resolvedSmtp = resolveAuthEmailSmtp(authDocument); const smtp: SmtpInput | undefined = resolvedSmtp === undefined ? undefined @@ -2692,12 +832,6 @@ export function resolveLocalConfigValues( adminEmail: resolvedSmtp.adminEmail, }; - const thirdParty = resolveThirdPartyProviders( - config.auth.third_party, - projectEnvValues, - remoteOverrideKeys, - ); - authInput = { siteUrl: siteUrl ?? "", captcha: captchaInput, @@ -2705,84 +839,33 @@ export function resolveLocalConfigValues( hooks, mfa, smtp, - thirdParty, + thirdParty: resolveThirdPartyProviders(config.auth.third_party), }; } const functionSlugs = Object.keys(config.functions); - // `edge_runtime.deno_version` is checked unconditionally, with no `edge_runtime.enabled` - // gate, unlike `studio.port`/`local_smtp.port` above. - const denoVersion = remoteWins("edge_runtime.deno_version") - ? config.edge_runtime.deno_version - : envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - - // When `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP fields are - // required. Backend-enum validation is covered at decode time for the config.toml-sourced - // value by `@supabase/config`'s schema, but not for the `SUPABASE_ANALYTICS_BACKEND` - // env-override path — see {@link envOverrideAnalyticsBackend} for that case. - const analyticsEnabled = remoteWins("analytics.enabled") - ? config.analytics.enabled - : envOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", - projectEnvValues, - ); - const analyticsBackend = envOverrideAnalyticsBackend( - config.analytics.backend, - projectEnvValues, - remoteWins("analytics.backend"), - ); - const gcpProjectId = remoteWins("analytics.gcp_project_id") - ? config.analytics.gcp_project_id - : envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - config.analytics.gcp_project_id, - projectEnvValues, - ); - const gcpProjectNumber = remoteWins("analytics.gcp_project_number") - ? config.analytics.gcp_project_number - : envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - config.analytics.gcp_project_number, - projectEnvValues, - ); - const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") - ? config.analytics.gcp_jwt_path - : envOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - config.analytics.gcp_jwt_path, - projectEnvValues, - ); + const denoVersion = config.edge_runtime.deno_version; + + const analyticsEnabled = config.analytics.enabled; + const analyticsBackend = narrowConfigEnum("analytics.backend", config.analytics.backend, [ + "postgres", + "bigquery", + ]); + const gcpProjectId = config.analytics.gcp_project_id; + const gcpProjectNumber = config.analytics.gcp_project_number; + const gcpJwtPath = config.analytics.gcp_jwt_path; // The webhooks check isn't "the user disabled a feature": an omitted `enabled` key in a // present `[experimental.webhooks]` section is rejected too — the section exists only so it - // can be turned on, never explicitly off. This hinges on TOML-section presence, which the + // can be turned on, never explicitly off. This hinges on section presence, which the // schema's decode-time default erases (`experimental.webhooks` always decodes to - // `{ enabled: false }` even when absent), so this reads the raw document instead. + // `{ enabled: false }` even when absent), so this reads the document instead. const experimentalDocument = asRecord(document?.["experimental"]); const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; - // A malformed JSON override needs no separate error path: it flows through unchanged and - // `validateResolvedConfig`'s existing JSON-validity check reports it. - const webhooksEnabled = remoteWins("experimental.webhooks.enabled") - ? config.experimental.webhooks?.enabled === true - : envOverrideBool( - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - config.experimental.webhooks?.enabled === true, - "experimental.webhooks.enabled", - projectEnvValues, - ); - const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") - ? (config.experimental.pgdelta?.format_options ?? "") - : (envOverride( - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - config.experimental.pgdelta?.format_options, - projectEnvValues, - ) ?? ""); + const webhooksEnabled = config.experimental.webhooks?.enabled === true; + const pgdeltaFormatOptions = config.experimental.pgdelta?.format_options ?? ""; // Every pure validation check runs in one place, here, rather than interleaved with this - // function's 3 I/O reads (signing keys, api.tls cert/key, email content) at their original - // relative positions — a config broken in two independent ways can report a different one of - // those errors than if checks ran interleaved. + // function's 3 I/O reads (signing keys, api.tls cert/key, email content). const apiInput: ApiInput = { enabled: apiEnabled, port: apiPort, @@ -2822,48 +905,29 @@ export function resolveLocalConfigValues( experimental: experimentalInput, }; validateResolvedConfig(input); - // sms/external run after the shared validation call, since both need the env-override-aware - // resolved value rather than the raw decoded config it validates. if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); - validateAuthExternalProviders( - authDocument, - config.auth.external, - projectEnvValues, - remoteOverrideKeys, - ); + validateAuthSmsProviders(resolveAuthSms(config.auth.sms)); + validateAuthExternalProviders(resolveAuthExternalProviders(authDocument, config.auth.external)); } - // Decrypted like `auth.email.smtp.pass`/`auth.captcha.secret`. - const openaiApiKey = remoteWins("studio.openai_api_key") - ? decryptAuthSecret(config.studio.openai_api_key, projectEnvValues) - : decryptAuthSecret( - envOverride( - "SUPABASE_STUDIO_OPENAI_API_KEY", - config.studio.openai_api_key, - projectEnvValues, - ), - projectEnvValues, - ); - return { apiUrl: apiExternalUrl, apiPort, dbPort, studioPort, rootKey, - openaiApiKey, + openaiApiKey: config.studio.openai_api_key, authSiteUrl: siteUrl, - authJwtIssuer: jwtIssuer, - authJwtExpiry: jwtExpiry, - authAdditionalRedirectUrls: additionalRedirectUrls, - authEnableSignup: enableSignup, - authEnableAnonymousSignIns: enableAnonymousSignIns, - authEnableRefreshTokenRotation: enableRefreshTokenRotation, - authRefreshTokenReuseInterval: refreshTokenReuseInterval, - authEnableManualLinking: enableManualLinking, - authMinimumPasswordLength: minimumPasswordLength, - authPasswordRequirements: passwordRequirements, + authJwtIssuer: config.auth.jwt_issuer, + authJwtExpiry: config.auth.jwt_expiry, + authAdditionalRedirectUrls: config.auth.additional_redirect_urls, + authEnableSignup: config.auth.enable_signup, + authEnableAnonymousSignIns: config.auth.enable_anonymous_sign_ins, + authEnableRefreshTokenRotation: config.auth.enable_refresh_token_rotation, + authRefreshTokenReuseInterval: config.auth.refresh_token_reuse_interval, + authEnableManualLinking: config.auth.enable_manual_linking, + authMinimumPasswordLength: config.auth.minimum_password_length, + authPasswordRequirements: config.auth.password_requirements, restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), @@ -2871,52 +935,12 @@ export function resolveLocalConfigValues( studioUrl: `http://${hostname}:${studioPort}`, mailpitUrl: `http://${hostname}:${mailpitPort}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, - // Decrypted like `anon_key`/`service_role_key` below. - publishableKey: resolveOpaqueKey( - remoteWins("auth.publishable_key") - ? decryptAuthSecret(config.auth.publishable_key, projectEnvValues) - : decryptAuthSecret( - envOverride( - "SUPABASE_AUTH_PUBLISHABLE_KEY", - config.auth.publishable_key, - projectEnvValues, - ), - projectEnvValues, - ), - defaultPublishableKey, - ), - secretKey: resolveOpaqueKey( - remoteWins("auth.secret_key") - ? decryptAuthSecret(config.auth.secret_key, projectEnvValues) - : decryptAuthSecret( - envOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), - projectEnvValues, - ), - defaultSecretKey, - ), + publishableKey: resolveOpaqueKey(config.auth.publishable_key, defaultPublishableKey), + secretKey: resolveOpaqueKey(config.auth.secret_key, defaultSecretKey), jwtSecret, - anonKey: resolveSignedKey( - decryptAuthSecret( - remoteWins("auth.anon_key") - ? config.auth.anon_key - : envOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), - projectEnvValues, - ), - jwtSecret, - signingKey, - "anon", - ), + anonKey: resolveSignedKey(config.auth.anon_key, jwtSecret, signingKey, "anon"), serviceRoleKey: resolveSignedKey( - decryptAuthSecret( - remoteWins("auth.service_role_key") - ? config.auth.service_role_key - : envOverride( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - config.auth.service_role_key, - projectEnvValues, - ), - projectEnvValues, - ), + config.auth.service_role_key, jwtSecret, signingKey, "service_role", @@ -2932,7 +956,7 @@ export function resolveLocalConfigValues( gcpJwtPath: gcpJwtPath ?? "", // Sanitized here, not in `input.projectId` above: `validateResolvedConfig`'s check is // presence-only and must see the raw value to reject an explicit `project_id = ""`. - projectId: sanitizeProjectId(resolvedProjectId ?? ""), + projectId: sanitizeProjectId(resolvedProjectId), edgeRuntimeDenoVersion: denoVersion, }; } @@ -2942,151 +966,25 @@ export const resolveLocalJwks = Effect.fnUntraced(function* ( config: CliConfig, workdir: string, jwtSecret: string, - projectEnvValues?: Readonly>, - remoteOverrideKeys: ReadonlySet = new Set(), ) { const { issuerUrl, signingKeys, signingKeysPath } = yield* Effect.try({ try: () => { - const remoteWins = makeRemoteWins(remoteOverrideKeys); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const signingKeysPath = config.auth.signing_keys_path; // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's // only ever replaced by a configured `signing_keys_path` file, and only when that file is // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS // resolution always publishes either the file's keys or this default, never neither — // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. - const signingKeys: ReadonlyArray = resolveConfiguredSigningKeys( - config, - workdir, - projectEnvValues, - remoteOverrideKeys, - ) ?? [DEFAULT_SIGNING_KEY]; - - // Built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's - // `tenant_region`) rather than the validation-only `ThirdPartyInput`, since - // {@link resolveThirdPartyIssuerUrl} needs the full set to build the issuer URL. - const thirdParty: ThirdPartyProvidersLike = { - firebase: { - enabled: remoteWins("auth.third_party.firebase.enabled") - ? config.auth.third_party.firebase.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: remoteWins("auth.third_party.firebase.project_id") - ? config.auth.third_party.firebase.project_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), - }, - auth0: { - enabled: remoteWins("auth.third_party.auth0.enabled") - ? config.auth.third_party.auth0.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: remoteWins("auth.third_party.auth0.tenant") - ? config.auth.third_party.auth0.tenant - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: remoteWins("auth.third_party.auth0.tenant_region") - ? config.auth.third_party.auth0.tenant_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), - }, - aws_cognito: { - enabled: remoteWins("auth.third_party.aws_cognito.enabled") - ? config.auth.third_party.aws_cognito.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? config.auth.third_party.aws_cognito.user_pool_id - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? config.auth.third_party.aws_cognito.user_pool_region - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), - }, - clerk: { - enabled: remoteWins("auth.third_party.clerk.enabled") - ? config.auth.third_party.clerk.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: remoteWins("auth.third_party.clerk.domain") - ? config.auth.third_party.clerk.domain - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), - }, - workos: { - enabled: remoteWins("auth.third_party.workos.enabled") - ? config.auth.third_party.workos.enabled - : envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: remoteWins("auth.third_party.workos.issuer_url") - ? config.auth.third_party.workos.issuer_url - : envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), - }, - }; - - // This function is called unconditionally, regardless of `auth.enabled`, but + const signingKeys: ReadonlyArray = resolveConfiguredSigningKeys(config, workdir) ?? [ + DEFAULT_SIGNING_KEY, + ]; + // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only - // meaningful while auth is enabled — `resolveLocalConfigValues` already ran the equivalent - // check in that case. When auth is disabled, that validation is (correctly) skipped, so this - // uses the unchecked, no-throw issuer-url builder instead. - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); + // meaningful while auth is enabled; when auth is disabled they are skipped, so the + // unchecked, no-throw issuer-url builder applies instead. + const thirdParty = config.auth.third_party; const issuerUrl = Option.fromNullishOr( - authEnabled + config.auth.enabled ? resolveThirdPartyIssuerUrl(thirdParty) : thirdPartyIssuerUrlUnchecked(thirdParty), ).pipe(Option.filter((value) => value.length > 0)); diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index a2b1687aeb..1acfcba923 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -9,35 +9,18 @@ import { importJWK, jwtVerify } from "jose"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { CliConfigValueError } from "../config/cli-config.errors.ts"; import { DEFAULT_SIGNING_KEY } from "./local-jwt.ts"; import { POSTGRES_DEFAULT_ROOT_KEY, - InvalidAnalyticsBackendEnvOverrideError, - InvalidBoolEnvOverrideError, - InvalidEdgeRuntimePolicyEnvOverrideError, InvalidJwtSecretError, - InvalidPoolModeEnvOverrideError, - InvalidPortEnvOverrideError, - InvalidRealtimeIpVersionEnvOverrideError, - InvalidSessionReplicationRoleEnvOverrideError, - envOverrideApiMaxRows, - envOverrideDefaultPoolSize, - envOverrideEdgeRuntimePolicy, - envOverrideMajorVersion, - envOverrideMaxClientConn, - envOverridePoolMode, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, + narrowConfigEnum, rawUnmodeledBool, resolveAuthCaptcha, resolveAuthEmail, - resolveAuthEmailSmtp, resolveAuthExternalProviders, - resolveAuthExternalUrl, resolveAuthHooks, - resolveAuthMfa, resolveAuthSms, - resolveConfiguredSigningKeys, resolveDbSettingsEnvOverrides, resolveLocalConfigValues, resolveLocalJwks, @@ -164,63 +147,6 @@ describe("resolveLocalConfigValues", () => { ); }); - describe("encrypted auth secrets", () => { - // This ciphertext decrypts to "value" under the keypair below. - const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const VAULT_ENCRYPTED = - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; - - afterEach(() => { - delete process.env["DOTENV_PRIVATE_KEY"]; - }); - - it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, - ); - }); - - it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.publishableKey).toBe("value"); - }); - - it("fails config loading for an encrypted: secret with no private key", () => { - const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("decrypts an encrypted: auth.email.smtp.pass", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const document = { auth: { email: { smtp: { enabled: true, pass: VAULT_ENCRYPTED } } } }; - const resolved = resolveAuthEmailSmtp(document.auth, undefined); - expect(resolved?.pass).toBe("value"); - }); - - it("decrypts an encrypted: studio.openai_api_key", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig({ studio: { openai_api_key: VAULT_ENCRYPTED } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.openaiApiKey).toBe("value"); - }); - - it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; - const config = baseConfig(); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, - }); - expect(values.secretKey).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; - }); - }); - it("rejects an explicit empty project_id", () => { // An explicit `project_id = ""` overwrites the workdir-basename default with the literal // empty string, unlike an absent key. @@ -230,38 +156,6 @@ describe("resolveLocalConfigValues", () => { ); }); - it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an absent project_id when the workdir basename sanitizes to empty", () => { - // The workdir-basename default still applies with no `project_id` key present, so a workdir - // whose basename sanitizes to empty (e.g. `!!!`) still fails validation. - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( - "Missing required field in config: project_id", - ); - }); - - it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { - const config = Schema.decodeUnknownSync(CliConfigSchema)({}); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); - }); - - it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { - const config = baseConfig({ project_id: "" }); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); - }); - it("hardcodes the local S3 credentials", () => { const config = baseConfig(); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); @@ -272,259 +166,9 @@ describe("resolveLocalConfigValues", () => { expect(values.storageS3Region).toBe("local"); }); - describe("SUPABASE_AUTH_* env overrides", () => { - const tempRoot = useTempWorkdir("supabase-signing-keys-env-override-test-"); - - 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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidJwtSecretError, - ); - }); - - 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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 ", () => { - // `env(VAR)` indirection resolves inside any string field, including a `SUPABASE_*` override - // value itself, not just a config.toml literal. - 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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("preserves the env(VAR) literal when the indirected var is unset", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); - }); - }); - - describe("non-auth SUPABASE_* env overrides", () => { - 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 and the exposed dbPort", () => { - process.env["SUPABASE_DB_PORT"] = "54329"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); - expect(values.dbPort).toBe(54329); - }); - - it("overrides studio.port for the derived Studio URL", () => { - process.env["SUPABASE_STUDIO_PORT"] = "54330"; - const config = baseConfig({ studio: { port: 54323 } }); - const values = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://env-override.example"); - }); - - it("treats an empty non-auth env var as unset", () => { - process.env["SUPABASE_DB_PORT"] = ""; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); - }); - - 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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, - ); - }); - - it("rejects a SUPABASE_DB_PORT override above the 65535 port maximum", () => { - process.env["SUPABASE_DB_PORT"] = "99999"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, - ); - }); - - it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_PORT"] = "010"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:8/postgres"); - }); - - it("resolves a 0x-prefixed SUPABASE_DB_PORT override as hex", () => { - process.env["SUPABASE_DB_PORT"] = "0x1F90"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8080); - }); - - it("still resolves a plain decimal SUPABASE_DB_PORT override with no leading zero", () => { - process.env["SUPABASE_DB_PORT"] = "5432"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(5432); - }); - - it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the 65535 port maximum", () => { - process.env["SUPABASE_DB_PORT"] = "0x1FFFF"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidPortEnvOverrideError, - ); - }); - + describe("non-auth port validation", () => { // Unlike the malformed/out-of-range cases above, db.port=0 is a required-field failure with // no `enabled` gate, unlike api.port/studio.port/local_smtp.port. - it("rejects a zero SUPABASE_DB_PORT override", () => { - process.env["SUPABASE_DB_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: db.port", - ); - }); - // api.enabled defaults to true, so this rejection applies without an explicit // `api.enabled = true`. it("rejects a configured api.port of 0 when api is enabled", () => { @@ -534,14 +178,6 @@ describe("resolveLocalConfigValues", () => { ); }); - it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { - process.env["SUPABASE_API_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); @@ -556,14 +192,6 @@ describe("resolveLocalConfigValues", () => { ); }); - it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { - process.env["SUPABASE_STUDIO_PORT"] = "0"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); @@ -586,14 +214,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, - ); - }); - // local_smtp.enabled defaults to true, so this rejection applies without an explicit // `local_smtp.enabled = true`. it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { @@ -603,81 +223,12 @@ describe("resolveLocalConfigValues", () => { ); }); - 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); }); - describe("db.major_version (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED env override", () => { - // Always validated eagerly, with no presence/enabled gate — same bucket as - // db.port/db.major_version above. - afterEach(() => { - delete process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; - }); - - it("does not throw for a valid SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "notabool"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, - ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for db.network_restrictions.enabled: "notabool" is not a valid boolean', - ); - }); - }); - describe("db.root_key (unmodeled raw-document field)", () => { it("falls back to the default root key when absent", () => { const config = baseConfig(); @@ -688,108 +239,22 @@ describe("resolveLocalConfigValues", () => { it("uses a configured string root_key verbatim", () => { const config = baseConfig(); const document = { db: { root_key: "custom-root-key" } }; - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document); expect(values.rootKey).toBe("custom-root-key"); }); it("rejects a non-string root_key (e.g. a bare TOML integer)", () => { const config = baseConfig(); const document = { db: { root_key: 12345 } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("failed to parse config:\ndb.root_key: expected a table"); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).toThrow( + "failed to parse config:\ndb.root_key: expected a table", + ); }); }); // storage.buckets validation lives entirely in config-validate.unit.test.ts; this file has no // bucket-related mechanics of its own. - // Not gated on edge_runtime.enabled — an invalid value is rejected unconditionally. - describe("edge_runtime.deno_version (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("analytics (BigQuery backend required fields)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidAnalyticsBackendEnvOverrideError, - ); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.backend: "mysql" must be one of "postgres", "bigquery"', - ); - }); - }); - describe("experimental.* (experimental.validate())", () => { // Exercises this function's own fallback when no `document` (5th param) is supplied; the // required-field/enabled checks themselves live in config-validate.unit.test.ts. @@ -797,3352 +262,582 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig({ experimental: { webhooks: {} } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + }); - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - afterEach(() => { - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; + describe("resolveAuthCaptcha", () => { + it("returns undefined when captcha is not configured", () => { + expect(resolveAuthCaptcha(undefined)).toBeUndefined(); }); + }); - it("enables webhooks purely via SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when the section omits enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "true"; - const config = baseConfig({ experimental: { webhooks: {} } }); - const document = { experimental: { webhooks: {} } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED override on an already-enabled section", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "notabool"; - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - const document = { experimental: { webhooks: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(InvalidBoolEnvOverrideError); - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - 'Invalid config for experimental.webhooks.enabled: "notabool" is not a valid boolean', - ); - }); - - it("rejects an invalid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - ); - }); - - it("accepts a valid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = '{"keywordCase":"upper"}'; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + describe("resolveAuthEmail", () => { + it("keeps an explicit empty subject present in the raw document, not omitted", () => { + const config = baseConfig({ + auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, + }); + const authDocument = { email: { template: { confirmation: { subject: "" } } } }; + const resolved = resolveAuthEmail(config.auth.email, authDocument); + expect(resolved.template["confirmation"]?.subject).toBe(""); }); - it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; + it("omits the subject when the key is absent from the raw document", () => { const config = baseConfig({ - experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + auth: { email: { template: { confirmation: { content_path: "x" } } } }, }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.pgdelta.format_options"]), - ), - ).not.toThrow(); + const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; + const resolved = resolveAuthEmail(config.auth.email, authDocument); + expect(resolved.template["confirmation"]?.subject).toBeUndefined(); }); }); - describe("SUPABASE_API_TLS_ENABLED env override", () => { - // Applied before the default `api.external_url` scheme is derived, so it can flip http/https - // even when config.toml disagrees. - 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 = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); + describe("resolveAuthHooks", () => { + const baseHook = { enabled: false, uri: "", secrets: "" }; + const allHooks = { + mfa_verification_attempt: baseHook, + password_verification_attempt: baseHook, + custom_access_token: baseHook, + send_sms: baseHook, + send_email: baseHook, + before_user_created: baseHook, + }; - 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 = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + it("leaves every hook disabled when nothing is configured or overridden", () => { + const resolved = resolveAuthHooks(allHooks); + expect(resolved.customAccessToken.enabled).toBe(false); + expect(resolved.mfaVerificationAttempt.enabled).toBe(false); }); + }); - 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 = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://config.example"); + describe("resolveAuthExternalProviders", () => { + it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { + const authDocument = { + external: { + my_custom: { + enabled: "true", + client_id: "custom-client-id", + skip_nonce_check: "false", + email_optional: "TRUE", + }, + }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(true); + expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); + expect(resolved["my_custom"]?.emailOptional).toBe(true); }); - 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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, + it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, + }; + expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external)).toThrow( + '"not-a-bool" is not a valid boolean', ); }); - it("treats an empty override as unset", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = ""; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); - }); - - describe("envOverrideRealtimeIpVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_IP_VERSION"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv4"); + it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { + const authDocument = { + external: { my_custom: { client_id: "custom-client-id" } }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(false); }); - it("overrides IPv4 to IPv6 via the env var", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; - expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv6"); + it("weakly coerces a raw numeric custom-provider boolean by truthiness", () => { + const authDocument = { + external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, + }; + const resolved = resolveAuthExternalProviders(authDocument, baseConfig().auth.external); + expect(resolved["my_custom"]?.enabled).toBe(true); }); - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - InvalidRealtimeIpVersionEnvOverrideError, - ); - expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - 'Invalid config for realtime.ip_version: "IPv5" must be one of "IPv4", "IPv6"', + it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, + }; + expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external)).toThrow( + '"1,2" is not a valid boolean', ); }); }); - describe("envOverrideRealtimeMaxHeaderLength", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; + describe("rawUnmodeledBool", () => { + it("returns false for an absent value", () => { + expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); }); - it("falls back to the configured value when unset", () => { - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(4096); + it("passes a real boolean through unchanged", () => { + expect(rawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; - expect(envOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(8192); + it("coerces a raw number by truthiness", () => { + expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); + expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); }); - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - envOverrideRealtimeMaxHeaderLength(4096, { - SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", - }), - ).toBe(16384); + it("parses boolean-ish strings", () => { + expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); + expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); + expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); }); - it("rejects an override exceeding the uint64 max (2^64)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", + it("throws on an unparsable string instead of silently disabling it", () => { + expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( + '"not-a-bool" is not a valid boolean', ); }); - it("accepts an override of exactly the uint64 max (2^64-1)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); - }); - - it("rejects a hex override exceeding the uint64 max (2^64)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; - expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", + it("throws on an array or table value instead of coercing it", () => { + expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow(CliConfigValueError); + expect(() => rawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( + CliConfigValueError, ); }); }); - describe("envOverrideApiMaxRows", () => { - afterEach(() => { - delete process.env["SUPABASE_API_MAX_ROWS"]; + describe("resolveDbSettingsEnvOverrides", () => { + it("returns the configured settings unchanged when nothing is overridden", () => { + const settings = { shared_buffers: "128MB", max_connections: 100 }; + expect(resolveDbSettingsEnvOverrides(settings)).toEqual(settings); }); - it("falls back to the configured value when unset", () => { - expect(envOverrideApiMaxRows(1000, undefined)).toBe(1000); + it("leaves an unconfigured field undefined when nothing is overridden", () => { + expect(resolveDbSettingsEnvOverrides({}).effective_cache_size).toBeUndefined(); }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_API_MAX_ROWS"] = "500"; - expect(envOverrideApiMaxRows(1000, undefined)).toBe(500); + it("leaves session_replication_role undefined when neither configured nor overridden", () => { + expect(resolveDbSettingsEnvOverrides({}).session_replication_role).toBeUndefined(); }); }); - // Exercises `envOverrideMajorVersion`'s base-0 parsing directly; most literals below don't - // correspond to a supported Postgres major version, which the full pipeline would separately - // reject. - describe("envOverrideMajorVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - }); + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useTempWorkdir("supabase-signing-keys-test-"); - it("falls back to the configured value when unset", () => { - expect(envOverrideMajorVersion(17, undefined)).toBe(17); - }); + 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 = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - it("resolves an octal leading-zero override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "010"; - expect(envOverrideMajorVersion(17, undefined)).toBe(8); - }); + 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" }); - it("resolves a 0x-prefixed override as hex", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0x10"; - expect(envOverrideMajorVersion(17, undefined)).toBe(16); + const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); }); - it("resolves a 0b-prefixed override as binary", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0b101"; - expect(envOverrideMajorVersion(17, undefined)).toBe(5); + 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 = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey.split(".")).toHaveLength(3); }); - it("still resolves a plain decimal override with no leading zero", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - expect(envOverrideMajorVersion(17, undefined)).toBe(15); + 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 = resolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); + expect(values.anonKey.split(".")).toHaveLength(3); }); - it("permits an underscore digit separator between decimal digits", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; - expect(envOverrideMajorVersion(17, undefined)).toBe(1000); + 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 = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); }); - it("rejects an invalid octal digit instead of silently falling back to decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "08"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: 08.", - ); + 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 = resolveLocalConfigValues(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("rejects a signed override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "+5"; - expect(() => envOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: +5.", + it("throws an error when the signing keys file does not exist", () => { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", ); }); - }); - describe("envOverridePoolMode", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; + it("throws an 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + 'failed to decode signing keys: JSON Parse error: Unexpected identifier "not"', + ); }); - it("falls back to the configured value when unset", () => { - expect(envOverridePoolMode("transaction", undefined)).toBe("transaction"); + 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "unsupported algorithm: RS512", + ); }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; - expect(envOverridePoolMode("transaction", undefined)).toBe("session"); + it("skips reading a missing signing_keys_path when auth is disabled", () => { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( - InvalidPoolModeEnvOverrideError, - ); - expect(() => envOverridePoolMode("transaction", undefined)).toThrow( - 'Invalid config for db.pooler.pool_mode: "invalid" must be one of "transaction", "session"', + it("skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", async () => { + 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 = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + // Disabled auth with a configured signing-keys path still signs with the default ES256 key, + // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. + const publicKey = await importJWK( + { ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, + "ES256", ); + const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); }); }); - describe("envOverrideEdgeRuntimePolicy", () => { - afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("oneshot"); - }); + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; - expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("per_worker"); - }); + describe("auth.passkey / auth.webauthn env overrides", () => { + // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread + // a raw `document` object through explicitly instead of relying on `baseConfig`. - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - InvalidEdgeRuntimePolicyEnvOverrideError, - ); - expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - 'Invalid config for edge_runtime.policy: "invalid" must be one of "per_worker", "oneshot"', + it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { + const config = baseConfig(); + const document = { auth: { passkey: { enabled: "not-a-bool" } } }; + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).toThrow( + '"not-a-bool" is not a valid boolean', ); }); }); - describe("envOverrideDefaultPoolSize", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"]; - }); - - it("falls back to the configured value when unset", () => { - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(20); - }); + describe("auth.email.template/notification (content_path validation)", () => { + const tempRoot = useTempWorkdir("supabase-email-templates-test-"); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"] = "40"; - expect(envOverrideDefaultPoolSize(20, undefined)).toBe(40); + 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); }); - }); - describe("envOverrideMaxClientConn", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"]; + it("rejects an absolute template content_path outside the project root", () => { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "/etc/hosts" } } }, + }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', + ); }); - it("falls back to the configured value when unset", () => { - expect(envOverrideMaxClientConn(100, undefined)).toBe(100); - }); - - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"] = "200"; - expect(envOverrideMaxClientConn(100, undefined)).toBe(200); - }); - }); - - describe("resolveAuthCaptcha", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); - - it("returns undefined when captcha is not configured", () => { - expect(resolveAuthCaptcha(undefined, undefined, undefined)).toBeUndefined(); - }); - - it("overrides enabled/provider when the section is present in the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: false, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(true); - expect(resolved?.provider).toBe("turnstile"); - }); - - it("does not apply an env override when [auth.captcha] is absent from the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const resolved = resolveAuthCaptcha( - {}, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(false); - }); - - it("decrypts an encrypted: captcha secret", () => { - process.env["DOTENV_PRIVATE_KEY"] = - "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { - enabled: true, - provider: "hcaptcha", - secret: - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", - }, - undefined, - ); - expect(resolved?.secret).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ), - ).toThrow('"not-a-bool" is not a valid boolean'); - }); - - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - new Set(["auth.captcha.secret"]), - ); - expect(resolved?.secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - expect(() => - resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - ), - ).toThrow("failed to parse config: missing private key"); - }); - - it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.provider"]), - ); - expect(resolved?.provider).toBe("hcaptcha"); - }); - - it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = resolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.provider).toBe("turnstile"); - }); - }); - - describe("resolveAuthEmail", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; - }); - - it("keeps an explicit empty subject present in the raw document, not omitted", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { subject: "" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe(""); - }); - - it("omits the subject when the key is absent from the raw document", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBeUndefined(); - }); - - it("prefers an env-overridden subject over the raw document's presence, even when absent", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Overridden subject"; - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, - }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = resolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); - }); - - describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; - }); - - it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail( - config.auth.email, - undefined, - undefined, - new Set(["auth.email.max_frequency"]), - ); - expect(resolved.max_frequency).toBe("1m"); - }); - - it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = resolveAuthEmail(config.auth.email, undefined, undefined); - expect(resolved.max_frequency).toBe("5s"); - }); - }); - }); - - describe("resolveAuthHooks", () => { - const baseHook = { enabled: false, uri: "", secrets: "" }; - const allHooks = { - mfa_verification_attempt: baseHook, - password_verification_attempt: baseHook, - custom_access_token: baseHook, - send_sms: baseHook, - send_email: baseHook, - before_user_created: baseHook, - }; - - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; - }); - - it("leaves every hook disabled when nothing is configured or overridden", () => { - const resolved = resolveAuthHooks(undefined, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); - - it("overrides enabled/uri when the hook's section is present in the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://example.com/hook"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - const resolved = resolveAuthHooks(authDocument, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(true); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); - - it("does not apply an env override when the hook's section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - const resolved = resolveAuthHooks({}, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); - }); - - it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => - resolveAuthHooks( - authDocument, - allHooks, - undefined, - new Set(["auth.hook.custom_access_token.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => resolveAuthHooks(authDocument, allHooks, undefined)).toThrow( - '"not-a-bool" is not a valid boolean', - ); - }); - - it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const hooksWithRemoteUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteUri, - undefined, - new Set(["auth.hook.custom_access_token.uri"]), - ); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - }); - - it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; - const hooksWithRemoteSecrets = { - ...allHooks, - custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks( - authDocument, - hooksWithRemoteSecrets, - undefined, - new Set(["auth.hook.custom_access_token.secrets"]), - ); - expect(resolved.customAccessToken.secrets).toBe("remote-secret"); - }); - - it("still applies the env override for uri when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; - const hooksWithLocalUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = resolveAuthHooks(authDocument, hooksWithLocalUri, undefined); - expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); - }); - }); - - describe("resolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => - resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => resolveAuthMfa(mfa, undefined)).toThrow('"not-a-bool" is not a valid boolean'); - }); - - it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"])); - expect(resolved.phone.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; - }); - - it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = resolveAuthMfa(mfa, undefined); - expect(resolved.phone.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; - }); - - it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = resolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.max_frequency"])); - expect(resolved.phone.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); - - it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = resolveAuthMfa(mfa, undefined); - expect(resolved.phone.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); - }); - - describe("resolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => - resolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( - '"not-a-bool" is not a valid boolean', - ); - }); - - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.pass"]), - ); - expect(resolved?.pass).toBe("remote-pass"); - }); - - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.host"]), - ); - expect(resolved?.host).toBe("smtp.remote.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.host).toBe("smtp.env.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); - - it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.user"]), - ); - expect(resolved?.user).toBe("remote-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.user).toBe("env-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); - - it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.admin_email"]), - ); - expect(resolved?.adminEmail).toBe("remote@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.adminEmail).toBe("env@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.sender_name"]), - ); - expect(resolved?.senderName).toBe("Remote Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); - - it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = resolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.senderName).toBe("Env Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); - }); - - describe("resolveAuthExternalProviders", () => { - it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { - const authDocument = { - external: { - my_custom: { - enabled: "true", - client_id: "custom-client-id", - skip_nonce_check: "false", - email_optional: "TRUE", - }, - }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); - expect(resolved["my_custom"]?.emailOptional).toBe(true); - }); - - it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, - }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('"not-a-bool" is not a valid boolean'); - }); - - it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { - const authDocument = { - external: { my_custom: { client_id: "custom-client-id" } }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(false); - }); - - it("weakly coerces a raw numeric custom-provider boolean by truthiness", () => { - const authDocument = { - external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - }); - - it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, - }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('"1,2" is not a valid boolean'); - }); - - it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client-id", - SUPABASE_AUTH_EXTERNAL_APPLE_SECRET: "apple-secret", - SUPABASE_AUTH_EXTERNAL_APPLE_URL: "https://appleid.apple.com", - }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["apple"]).toEqual({ - enabled: true, - clientId: "apple-client-id", - secret: "apple-secret", - url: "https://appleid.apple.com", - redirectUri: "", - skipNonceCheck: false, - emailOptional: false, - }); - }); - - it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets the default-template exception", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", - }; - const resolved = resolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["google"]).toBeUndefined(); - expect(resolved["apple"]?.enabled).toBe(false); - }); - }); - - describe("resolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - it("prefers a remote-set auth.external..secret over a malformed SUPABASE_AUTH_EXTERNAL__SECRET", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.secret"]), - ); - expect(resolved["my_custom"]?.secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__SECRET when no remote block matched", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), - ).toThrow("failed to parse config: missing private key"); - }); - - it("prefers a remote-set auth.external..enabled over a malformed SUPABASE_AUTH_EXTERNAL__ENABLED", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__ENABLED when no remote block matched", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), - ).toThrow('"not-a-bool" is not a valid boolean'); - }); - - it("prefers a remote-set auth.external..client_id over a conflicting SUPABASE_AUTH_EXTERNAL__CLIENT_ID", () => { - const authDocument = { - external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, - }; - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", - }; - const resolved = resolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.client_id"]), - ); - expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); - }); - }); - - describe("rawUnmodeledBool", () => { - it("returns false for an absent value", () => { - expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); - }); - - it("passes a real boolean through unchanged", () => { - expect(rawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); - }); - - it("weakly coerces a raw number by truthiness", () => { - expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); - }); - - it("parses a valid boolean-ish string", () => { - expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); - expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); - expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); - }); - - it("throws on an unparsable string instead of silently disabling it", () => { - expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( - '"not-a-bool" is not a valid boolean', - ); - }); - - it("throws on an array or table value — never weakly coerced", () => { - expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( - InvalidBoolEnvOverrideError, - ); - expect(() => rawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - - describe("resolveDbSettingsEnvOverrides", () => { - const ALL_OVERRIDE_NAMES = [ - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - "SUPABASE_DB_SETTINGS_WORK_MEM", - ]; - - afterEach(() => { - for (const name of ALL_OVERRIDE_NAMES) delete process.env[name]; - }); - - it("returns the configured settings unchanged when nothing is overridden", () => { - const settings = { shared_buffers: "128MB", max_connections: 100 }; - expect(resolveDbSettingsEnvOverrides(settings, undefined)).toEqual(settings); - }); - - it("leaves an unconfigured field undefined when nothing is overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).effective_cache_size).toBeUndefined(); - }); - - it("overrides a string field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; - expect( - resolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, undefined).shared_buffers, - ).toBe("256MB"); - }); - - it("sets a string field via the env var even when not configured at all", () => { - process.env["SUPABASE_DB_SETTINGS_WORK_MEM"] = "8MB"; - expect(resolveDbSettingsEnvOverrides({}, undefined).work_mem).toBe("8MB"); - }); - - it("overrides a uint field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "200"; - expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(200); - }); - - it("rejects a non-numeric uint override", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Invalid db.settings.max_connections", - ); - }); - - it("resolves a 0x-prefixed uint override as hex", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; - expect( - resolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(16); - }); - - it("rejects a uint override exceeding the uint64 max (2^64)", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", - ); - }); - - it("overrides the boolean field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "true"; - expect( - resolveDbSettingsEnvOverrides({ track_commit_timestamp: false }, undefined) - .track_commit_timestamp, - ).toBe(true); - }); - - it("rejects a malformed boolean override", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "not-a-bool"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - - it("overrides the session_replication_role enum field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "replica"; - expect( - resolveDbSettingsEnvOverrides({ session_replication_role: "origin" }, undefined) - .session_replication_role, - ).toBe("replica"); - }); - - it("leaves session_replication_role undefined when neither configured nor overridden", () => { - expect(resolveDbSettingsEnvOverrides({}, undefined).session_replication_role).toBeUndefined(); - }); - - it("rejects an invalid session_replication_role override", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - InvalidSessionReplicationRoleEnvOverrideError, - ); - expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - 'Invalid config for db.settings.session_replication_role: "invalid" must be one of "origin", "replica", "local"', - ); - }); - - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - resolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" }) - .shared_buffers, - ).toBe("512MB"); - }); - }); - - describe("auth.signing_keys_path (asymmetric JWT signing)", () => { - const tempRoot = useTempWorkdir("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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 = resolveLocalConfigValues(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 descriptive error when the signing keys file does not exist", () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); - - it("throws a descriptive 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'failed to decode signing keys: JSON Parse error: Unexpected identifier "not"', - ); - }); - - 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "unsupported algorithm: RS512", - ); - }); - - it("skips reading a missing signing_keys_path when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "missing.json" }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", async () => { - 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 = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - // Disabled auth with a configured signing-keys path still signs with the default ES256 key, - // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. - const publicKey = await importJWK( - { ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, - "ES256", - ); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "ES256", kid: DEFAULT_SIGNING_KEY.kid }); - }); - - describe("SUPABASE_AUTH_ENABLED env override", () => { - // Reads the post-override `auth.enabled` value, not raw TOML, so an env-only disable/enable - // still gates whether `signing_keys_path` is read. - 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(() => resolveLocalConfigValues(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 = resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - }); - - describe("auth.site_url (required field in config)", () => { - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("exposes the overridden site_url on the returned values, not just for validation", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; - const config = baseConfig({ auth: { enabled: true, site_url: "http://127.0.0.1:3000" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authSiteUrl).toBe("http://localhost:4000"); - }); - }); - }); - - describe("auth.* flat scalar env overrides (GoTrue container env, not just validation)", () => { - const AUTH_SCALAR_ENV_KEYS = [ - "SUPABASE_AUTH_JWT_ISSUER", - "SUPABASE_AUTH_JWT_EXPIRY", - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - "SUPABASE_AUTH_ENABLE_SIGNUP", - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - ]; - afterEach(() => { - for (const key of AUTH_SCALAR_ENV_KEYS) delete process.env[key]; - }); - - it("overrides every flat auth.* scalar GoTrue needs, not just the ones Validate checks", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://issuer.example.com"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = - "https://a.example.com,https://b.example.com"; - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; - process.env["SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS"] = "true"; - process.env["SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION"] = "false"; - process.env["SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL"] = "20"; - process.env["SUPABASE_AUTH_ENABLE_MANUAL_LINKING"] = "true"; - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "12"; - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "lower_upper_letters_digits"; - - const config = baseConfig({ - auth: { - jwt_expiry: 3600, - additional_redirect_urls: [], - enable_signup: true, - enable_anonymous_sign_ins: false, - enable_refresh_token_rotation: true, - refresh_token_reuse_interval: 10, - enable_manual_linking: false, - minimum_password_length: 6, - password_requirements: "", - }, - }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - - expect(values.authJwtIssuer).toBe("https://issuer.example.com"); - expect(values.authJwtExpiry).toBe(7200); - expect(values.authAdditionalRedirectUrls).toEqual([ - "https://a.example.com", - "https://b.example.com", - ]); - expect(values.authEnableSignup).toBe(false); - expect(values.authEnableAnonymousSignIns).toBe(true); - expect(values.authEnableRefreshTokenRotation).toBe(false); - expect(values.authRefreshTokenReuseInterval).toBe(20); - expect(values.authEnableManualLinking).toBe(true); - expect(values.authMinimumPasswordLength).toBe(12); - expect(values.authPasswordRequirements).toBe("lower_upper_letters_digits"); - }); - - it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override", () => { - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid auth.password_requirements: bogus", - ); - }); - }); - - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - - describe("auth.captcha env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); - - it("rejects a captcha section enabled only via env with no provider", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig({ auth: { captcha: { enabled: false } } }); - const document = { auth: { captcha: { enabled: false } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.captcha.provider"); - }); - - it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("accepts env-provided provider/secret overriding an enabled captcha section", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.passkey / auth.webauthn env overrides", () => { - // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread - // a raw `document` object through explicitly instead of relying on `baseConfig`. - afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - }); - - it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - }); - - it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = - "http://localhost:3000,http://localhost:3001"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { - const config = baseConfig(); - const document = { auth: { passkey: { enabled: "not-a-bool" } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow('"not-a-bool" is not a valid boolean'); - }); - }); - - describe("auth.hook.* env overrides", () => { - // The hook schema always decodes a default `{ enabled: false }` regardless of file presence, - // so presence here is read from the raw `document`, not the decoded `config`. - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; - }); - - it("rejects a hook section enabled only via env with no uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { hook: { send_email: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.hook.send_email.uri"); - }); - - it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; - const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); - const document = { auth: { hook: { send_email: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.email.smtp env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); - - it("rejects an smtp section enabled only via env with no host", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.email.smtp.host"); - }); - - it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(InvalidPortEnvOverrideError); - }); - - it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.mfa env overrides", () => { - // These are plain value-typed fields with no presence gate, unlike hooks/smtp above — they're - // overridable unconditionally. - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; - }); - - it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", - ); - }); - - it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - InvalidBoolEnvOverrideError, - ); - }); - }); - - describe("auth.third_party env overrides", () => { - // Same as auth.mfa above — including workos, whose default template omits the whole section - // yet leaves it still unconditionally overridable. - afterEach(() => { - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; - }); - - it("rejects a third-party provider enabled only via env with no required field configured", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); - - it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - - it("does not enable a third-party provider purely from a required-field env override", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.email.template/notification (content_path validation)", () => { - const tempRoot = useTempWorkdir("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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); - - it("rejects an absolute template content_path outside the project root", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "/etc/hosts" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', - ); - }); - - 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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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 the workdir", () => { - const templateDir = join(tempRoot.current, "supabase", "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "pw-changed.html"), ""); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { - enabled: true, - content_path: "supabase/templates/pw-changed.html", - }, - }, - }, - }, - }); - expect(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - 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(() => - resolveLocalConfigValues(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", - ); - }); - }); - - describe("auth.email.template/notification env overrides", () => { - // No raw-document presence gate needed here: `email.template`/`email.notification` are - // `Schema.Record`s, so the decoded config already reflects presence. - const tempRoot = useTempWorkdir("supabase-email-template-env-test-"); - - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"]; - }); - - it("lets an env-provided template content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects a notification enabled only via env with a missing content_path file", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: false, content_path: "missing.html" } }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", - ); - }); - - it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, - }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("lets an env-provided notification content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "pw-changed.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("rejects a template _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content: please use content_path instead", - ); - }); - - it("rejects an enabled notification's _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content: please use content_path instead", - ); - }); - - it("does not validate a disabled notification's _CONTENT env override", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "Hi"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "Hi"; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.template.invite.content_path"]), - ), - ).not.toThrow(); - }); - - it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); - - it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), ""); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: true, content_path: "pw-changed.html" }, - }, - }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.content_path"]), - ), - ).not.toThrow(); - }); - - it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.enabled"]), - ), - ).not.toThrow(); - }); - }); - - // Required-field/range assertions live in config-validate.unit.test.ts; only env-override - // mechanics are tested here. - - describe("auth.external (external.validate(), D-only, ported to L)", () => { - // Unmodeled external providers are silently dropped by the decoded config, so this reads the - // raw `document` (5th param) instead. - it("rejects an enabled unmodeled external provider missing client_id", () => { - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.client_id"); - }); - - it("rejects an enabled unmodeled external provider missing secret", () => { - const config = baseConfig(); - const document = { - auth: { external: { custom: { enabled: true, client_id: "abc" } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.secret"); - }); - - it("does not require a secret for apple/google providers", () => { - const config = baseConfig(); - const document = { - auth: { external: { apple: { enabled: true, client_id: "abc" } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("skips deprecated linkedin/slack providers", () => { - const config = baseConfig(); - const document = { auth: { external: { slack: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not validate a disabled unmodeled external provider", () => { - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("skips the check entirely when no document is threaded through", () => { - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.external env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; - }); - - it("rejects a provider enabled only via env with no client_id", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.external.custom.client_id"); - }); - - it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; - const config = baseConfig(); - const document = { auth: { external: { custom: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.sms env overrides (provider switch)", () => { - // Validates only the first enabled provider, in priority order (twilio, twilio_verify, - // messagebird, textlocal, vonage), re-run here against the raw document with env overrides - // applied. - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; - delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; - }); - - it("rejects a provider enabled only via env with missing required fields", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { sms: { twilio: { enabled: false } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); - }); - - it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const config = baseConfig(); - const document = { auth: { sms: { twilio: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("only validates the first enabled provider in fixed priority order", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; - const config = baseConfig(); - const document = { - auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); - }); - - it("throws for a provider enabled only via env with missing required fields even when the document has no auth.sms section at all", () => { - // Unlike the other 4 providers, twilio's presence isn't gated on the document — the default - // config always registers `auth.sms.twilio.*`, so enabling only via env still fails - // validation instead of doing nothing. - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.sms.twilio.account_sid", - ); - }); - - it("resolves a fully env-only twilio configuration with no auth.sms.twilio document section", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.twilio.enabled).toBe(true); - expect(resolved.twilio.account_sid).toBe("AC123"); - expect(resolved.twilio.message_service_sid).toBe("MG123"); - expect(resolved.twilio.auth_token).toBe("tok"); - }); - - it("still does not synthesize messagebird purely from an env override when the section is absent from the document", () => { - // Unlike twilio, messagebird has no entry in the default config template, so an absent - // section genuinely means it was never registered. - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; - const config = baseConfig(); - const resolved = resolveAuthSms(undefined, config.auth.sms, undefined); - expect(resolved.messagebird.enabled).toBe(false); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("resolveAuthSms (top-level scalars)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"]; - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("overrides enable_signup/enable_confirmations/max_frequency/template with no presence gate", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; - // A provider must be enabled, or `enable_signup` gets downgraded to false regardless of the - // override. - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - expect(resolved.enable_confirmations).toBe(true); - expect(resolved.max_frequency).toBe("10s"); - expect(resolved.template).toBe("Your OTP is {{ .Code }}"); - }); - - it("leaves the scalars at their configured values when nothing is overridden", () => { - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - max_frequency: "5s", - twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - expect(resolved.max_frequency).toBe("5s"); - }); - }); - - describe("resolveAuthSms (disables phone login with no provider enabled)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - }); - - it("downgrades enable_signup to false when configured true with no provider enabled", () => { - const configured = { ...baseConfig().auth.sms, enable_signup: true }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(false); - }); - - it("downgrades an env-overridden enable_signup to false with no provider enabled", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.enable_signup).toBe(false); - }); - - it("leaves enable_signup alone when a provider is enabled", () => { - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.enable_signup).toBe(true); - }); - - it("leaves enable_signup at false when already false with no provider enabled", () => { - const resolved = resolveAuthSms(undefined, baseConfig().auth.sms, undefined); - expect(resolved.enable_signup).toBe(false); - }); - }); - - describe("resolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; - }); - - it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - enable_signup: true, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => resolveAuthSms(undefined, configured, undefined)).toThrow( - '"not-a-bool" is not a valid boolean', - ); - }); - - it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, - }; - expect(() => - resolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.vonage.enabled"])), - ).not.toThrow(); - }); - - it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, - }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), - ); - expect(resolved.vonage.api_secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; - const authDocument = { sms: { vonage: {} } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, - }; - expect(() => resolveAuthSms(authDocument, configured, undefined)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, - }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.twilio.account_sid"]), - ); - expect(resolved.twilio.account_sid).toBe("remote-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - }); - - it("still applies SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; - const configured = { - ...baseConfig().auth.sms, - twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, - }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.twilio.account_sid).toBe("env-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - }); - - it("prefers a remote-set auth.sms.vonage.from over a conflicting SUPABASE_AUTH_SMS_VONAGE_FROM", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; - const authDocument = { sms: { vonage: { from: "remote-from" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, - }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.from"]), - ); - expect(resolved.vonage.from).toBe("remote-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; - }); - - it("still applies SUPABASE_AUTH_SMS_VONAGE_FROM when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; - const authDocument = { sms: { vonage: { from: "remote-from" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, - }; - const resolved = resolveAuthSms(authDocument, configured, undefined); - expect(resolved.vonage.from).toBe("env-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; - }); - - it("prefers a remote-set auth.sms.vonage.api_key over a conflicting SUPABASE_AUTH_SMS_VONAGE_API_KEY", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; - const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, - }; - const resolved = resolveAuthSms( - authDocument, - configured, - undefined, - new Set(["auth.sms.vonage.api_key"]), - ); - expect(resolved.vonage.api_key).toBe("remote-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; - }); - - it("still applies SUPABASE_AUTH_SMS_VONAGE_API_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; - const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; - const configured = { - ...baseConfig().auth.sms, - vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, - }; - const resolved = resolveAuthSms(authDocument, configured, undefined); - expect(resolved.vonage.api_key).toBe("env-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; - }); - - it("prefers a remote-set auth.sms.template over a conflicting SUPABASE_AUTH_SMS_TEMPLATE", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; - const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.template"]), - ); - expect(resolved.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("still applies SUPABASE_AUTH_SMS_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; - const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; - }); - - it("prefers a remote-set auth.sms.max_frequency over a conflicting SUPABASE_AUTH_SMS_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; - const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms( - undefined, - configured, - undefined, - new Set(["auth.sms.max_frequency"]), - ); - expect(resolved.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - }); - - it("still applies SUPABASE_AUTH_SMS_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; - const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = resolveAuthSms(undefined, configured, undefined); - expect(resolved.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - }); - - it("still aborts resolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; - const base = baseConfig(); - const config: CliConfig = { - ...base, + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { + writeFileSync(join(tempRoot.current, "invite.html"), ""); + const config = baseConfig({ auth: { - ...base.auth, enabled: true, - sms: { - ...base.auth.sms, - enable_signup: true, - vonage: { - ...base.auth.sms.vonage, - enabled: true, - from: "12345", - api_key: "key", - api_secret: "secret", - }, - }, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, }, - }; - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - '"not-a-bool" is not a valid boolean', - ); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.sms.enable_signup"]), - ), - ).not.toThrow(); - }); - }); - - describe("api.tls (cert/key validation)", () => { - const tempRoot = useTempWorkdir("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", () => { - const config = baseConfig({ api: { tls: { enabled: true } } }); + }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read - // behavior below is tested here. - - it("throws a descriptive error when the configured cert file does not exist", () => { - writeTlsFile(tempRoot.current, "key.pem"); + it("does not throw a template with no content_path configured", () => { const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - it("throws a descriptive error when the configured key file does not exist", () => { - writeTlsFile(tempRoot.current, "cert.pem"); + it("rejects an enabled notification content_path pointing at a missing file", () => { const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS key: ", + "Invalid config for auth.email.notification.password_changed.content_path: ", ); }); - 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); - }); - - it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); + it("resolves a relative notification content_path against the workdir", () => { + const templateDir = join(tempRoot.current, "supabase", "templates"); + mkdirSync(templateDir, { recursive: true }); + writeFileSync(join(templateDir, "pw-changed.html"), ""); const config = baseConfig({ - api: { - tls: { - enabled: true, - cert_path: "/cert.pem", - key_path: "/key.pem", + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { + enabled: true, + content_path: "supabase/templates/pw-changed.html", + }, + }, }, }, }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - it("skips TLS validation entirely when api is disabled", () => { - const config = baseConfig({ - api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.key_path", - ); - }); - }); - }); -}); - -describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // Once a remote block sets a field, a conflicting `SUPABASE_*` env var must not be consulted - // for it — verified per field below. - afterEach(() => { - for (const name of [ - "SUPABASE_DB_MAJOR_VERSION", - "SUPABASE_AUTH_JWT_SECRET", - "SUPABASE_DB_ROOT_KEY", - "SUPABASE_API_PORT", - "SUPABASE_API_TLS_ENABLED", - "SUPABASE_API_EXTERNAL_URL", - "SUPABASE_DB_PORT", - "SUPABASE_AUTH_SITE_URL", - "SUPABASE_AUTH_JWT_EXPIRY", - "SUPABASE_AUTH_ANON_KEY", - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - "SUPABASE_STUDIO_API_URL", - "SUPABASE_STUDIO_OPENAI_API_KEY", - "SUPABASE_AUTH_PUBLISHABLE_KEY", - "SUPABASE_AUTH_SECRET_KEY", - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - "SUPABASE_AUTH_ENABLED", - "SUPABASE_ANALYTICS_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - "SUPABASE_EDGE_RUNTIME_DENO_VERSION", - "SUPABASE_API_ENABLED", - "SUPABASE_STUDIO_ENABLED", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_ENABLED", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_AUTH_ENABLE_SIGNUP", - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - "SUPABASE_AUTH_PASSKEY_ENABLED", - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", - ]) { - delete process.env[name]; - } - }); - - const tempRoot = useTempWorkdir("supabase-remote-signing-keys-test-"); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.signing_keys_path"]), - ), - ).not.toThrow(); - }); - - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); - - it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig({ db: { major_version: 14 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.major_version"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig({ db: { major_version: 14 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid db.major_version: abc", - ); - }); - - it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; - const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_secret"]), - ); - expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); - }); - - it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { - process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; - const config = baseConfig(); - const document = { db: { root_key: "remote-root-key" } }; - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["db.root_key"]), - ); - expect(values.rootKey).toBe("remote-root-key"); - }); - - it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; - const config = baseConfig({ - auth: { - enabled: true, - third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, - }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), - ), - ).not.toThrow(); - }); - - it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; - const config = baseConfig({ - auth: { - enabled: true, - third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, - }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.clerk has invalid domain", - ); - }); - - describe("api.tls.cert_path/key_path — remoteOverrideKeys (review: PRRT_kwDOErm0O86W8ZYk)", () => { - const tempRoot = useTempWorkdir("supabase-api-tls-remote-test-"); - - function writeTlsFile(workdir: string, name: string, contents = "dummy") { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); - } - - afterEach(() => { - delete process.env["SUPABASE_API_TLS_CERT_PATH"]; - delete process.env["SUPABASE_API_TLS_KEY_PATH"]; - }); - - it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { - // The env vars point at files that don't exist; the load only succeeds if the remote-set - // paths win instead. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["api.tls.cert_path", "api.tls.key_path"]), - ), - ).not.toThrow(); - }); - - it("still uses the env override when no remote block matched", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + it("does not throw a disabled notification's missing content_path", () => { const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, - }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); - }); - - it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { - process.env["SUPABASE_API_PORT"] = "9999"; - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; - const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.port", "api.tls.enabled", "api.external_url"]), - ); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); - }); - - it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { - process.env["SUPABASE_DB_PORT"] = "9999"; - const config = baseConfig({ db: { port: 54322 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.port"]), - ); - expect(values.dbPort).toBe(54322); - expect(values.dbUrl).toContain(":54322/postgres"); - }); - - it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; - const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.site_url", "auth.jwt_expiry"]), - ); - expect(values.authSiteUrl).toBe("https://remote.test"); - expect(values.authJwtExpiry).toBe(3600); - }); - - it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; - const config = baseConfig({ - auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, - }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.anon_key", "auth.service_role_key"]), - ); - expect(values.anonKey).toBe("remote-anon-key"); - expect(values.serviceRoleKey).toBe("remote-service-role-key"); - }); - - it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; - const config = baseConfig({ studio: { api_url: "http://remote.test" } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.api_url"]), - ), - ).not.toThrow(); - }); - - it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.openai_api_key"]), - ); - expect(values.openaiApiKey).toBe("remote-openai-key"); - }); - - it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ - auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, - }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.publishable_key", "auth.secret_key"]), - ); - expect(values.publishableKey).toBe("remote-publishable-key"); - expect(values.secretKey).toBe("remote-secret-key"); - }); - - it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; - const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "failed to parse config: missing private key", - ); - }); - - it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - const config = baseConfig({ db: { settings: { max_connections: 100 } } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["db.settings.max_connections"]), - ), - ).not.toThrow(); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', - ); - }); - - it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ analytics: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ analytics: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.enabled: "not-a-bool" is not a valid boolean', - ); - }); - - it("prefers a remote-set analytics.gcp_project_id over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_ID", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; - const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_id"]), - ); - expect(values.gcpProjectId).toBe("remote-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - }); - - it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_ID when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; - const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpProjectId).toBe("env-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - }); - - it("prefers a remote-set analytics.gcp_project_number over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; - const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_project_number"]), - ); - expect(values.gcpProjectNumber).toBe("111"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - }); - - it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; - const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpProjectNumber).toBe("999"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - }); - - it("prefers a remote-set analytics.gcp_jwt_path over a conflicting SUPABASE_ANALYTICS_GCP_JWT_PATH", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; - const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["analytics.gcp_jwt_path"]), - ); - expect(values.gcpJwtPath).toBe("remote-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); - - it("still applies SUPABASE_ANALYTICS_GCP_JWT_PATH when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; - const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.gcpJwtPath).toBe("env-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); - - it("prefers a remote-set auth.jwt_issuer over a conflicting SUPABASE_AUTH_JWT_ISSUER", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; - const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.jwt_issuer"]), - ); - expect(values.authJwtIssuer).toBe("https://remote.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; - }); - - it("still applies SUPABASE_AUTH_JWT_ISSUER when no remote block matched", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; - const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authJwtIssuer).toBe("https://env.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; - }); + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + }); - it("prefers a remote-set auth.additional_redirect_urls over a conflicting SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; - const config = baseConfig({ - auth: { additional_redirect_urls: ["https://remote.example.com"] }, + 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - const values = resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.additional_redirect_urls"]), - ); - expect(values.authAdditionalRedirectUrls).toEqual(["https://remote.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; - }); - it("still applies SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS when no remote block matched", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; - const config = baseConfig({ - auth: { additional_redirect_urls: ["https://remote.example.com"] }, + 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(() => + resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, { + auth: { email: { template: { invite: { content: "Hi" } } } }, + }), + ).toThrow( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); }); - const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authAdditionalRedirectUrls).toEqual(["https://env.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; }); - describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // rpId/rpOrigins aren't part of the return value, so precedence is proven through - // validateResolvedConfig's own emptiness check on a document that leaves the field present - // but empty. - afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - }); + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. - it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ID when a remote block already set (empty) auth.webauthn.rp_id", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + describe("auth.external (external.validate(), D-only, ported to L)", () => { + // Unmodeled external providers are silently dropped by the decoded config, so this reads the + // raw `document` (5th param) instead. + it("rejects an enabled unmodeled external provider missing client_id", () => { const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, - }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_id"]), - ), - ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + const document = { auth: { external: { custom: { enabled: true } } } }; + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).toThrow( + "Missing required field in config: auth.external.custom.client_id", + ); }); - it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ID when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + it("rejects an enabled unmodeled external provider missing secret", () => { const config = baseConfig(); const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, + auth: { external: { custom: { enabled: true, client_id: "abc" } } }, }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).toThrow( + "Missing required field in config: auth.external.custom.secret", + ); }); - it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when a remote block already set (empty) auth.webauthn.rp_origins", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + it("does not require a secret for apple/google providers", () => { const config = baseConfig(); const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, + auth: { external: { apple: { enabled: true, client_id: "abc" } } }, }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.webauthn.rp_origins"]), - ), - ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).not.toThrow(); }); - it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + it("skips deprecated linkedin/slack providers", () => { const config = baseConfig(); - const document = { - auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, - }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); + const document = { auth: { external: { slack: { enabled: true } } } }; + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).not.toThrow(); }); - }); - it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + it("does not validate a disabled unmodeled external provider", () => { + const config = baseConfig(); + const document = { auth: { external: { custom: { enabled: false } } } }; + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, document)).not.toThrow(); }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), - ), - ).not.toThrow(); - }); - it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + it("skips the check entirely when no document is threaded through", () => { + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.third_party.firebase.enabled: "not-a-bool" is not a valid boolean', - ); - }); - - it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["edge_runtime.deno_version"]), - ), - ).not.toThrow(); }); - it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: abc.", - ); - }); - - it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["api.enabled"]), - ), - ).not.toThrow(); + describe("resolveAuthSms (top-level scalars)", () => { + it("leaves the scalars at their configured values when nothing is overridden", () => { + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + max_frequency: "5s", + twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, + }; + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(true); + expect(resolved.max_frequency).toBe("5s"); + }); }); - it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for api.enabled: "not-a-bool" is not a valid boolean', - ); - }); + describe("resolveAuthSms (disables phone login with no provider enabled)", () => { + it("downgrades enable_signup to false when configured true with no provider enabled", () => { + const configured = { ...baseConfig().auth.sms, enable_signup: true }; + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(false); + }); - it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; - const config = baseConfig({ studio: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.enabled"]), - ), - ).not.toThrow(); - }); + it("leaves enable_signup alone when a provider is enabled", () => { + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + const resolved = resolveAuthSms(configured); + expect(resolved.enable_signup).toBe(true); + }); - it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; - const config = baseConfig({ studio: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for studio.enabled: "not-a-bool" is not a valid boolean', - ); + it("leaves enable_signup at false when already false with no provider enabled", () => { + const resolved = resolveAuthSms(baseConfig().auth.sms); + expect(resolved.enable_signup).toBe(false); + }); }); - it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { - process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; - const config = baseConfig({ studio: { port: 54323 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["studio.port"]), - ), - ).not.toThrow(); - }); + describe("api.tls (cert/key validation)", () => { + const tempRoot = useTempWorkdir("supabase-api-tls-test-"); - it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; - const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["local_smtp.enabled"]), - ), - ).not.toThrow(); - }); + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } - it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; - const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for local_smtp.enabled: "not-a-bool" is not a valid boolean', - ); - }); + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + }); - it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; - const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.enable_signup"]), - ), - ).not.toThrow(); - }); + // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read + // behavior below is tested here. - it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; - const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enable_signup: "not-a-bool" is not a valid boolean', - ); - }); + it("throws an 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); - it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; - const config = baseConfig({ auth: { minimum_password_length: 8 } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["auth.minimum_password_length"]), - ), - ).not.toThrow(); - }); + it("throws an 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); - it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.webhooks.enabled"]), - ), - ).not.toThrow(); - }); + 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(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); + }); - it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const config = baseConfig({ - auth: { - hook: { - custom_access_token: { + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { enabled: true, - uri: "https://example.com/hook", - secrets: `v1,whsec_${"A".repeat(32)}`, + cert_path: "/cert.pem", + key_path: "/key.pem", }, }, - }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - new Set(["auth.hook.custom_access_token.uri"]), - ), - ).not.toThrow(); - }); - it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const config = baseConfig({ - auth: { - hook: { - custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, - }, - }, + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; - expect(() => - resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); +}); + +describe("narrowConfigEnum", () => { + it("returns a configured value that is one of the allowed values", () => { + expect(narrowConfigEnum("realtime.ip_version", "IPv6", ["IPv4", "IPv6"])).toBe("IPv6"); + }); + + it("fails with the config value error naming every allowed value", () => { + expect(() => narrowConfigEnum("realtime.ip_version", "v9", ["IPv4", "IPv6"])).toThrow( + new CliConfigValueError({ + path: "realtime.ip_version", + tier: "config", + message: 'Invalid config for realtime.ip_version: "v9" must be one of "IPv4", "IPv6"', + }), + ); }); }); @@ -4222,14 +917,14 @@ describe("resolveLocalJwks", () => { }); }); - it("throws a descriptive error when the signing keys file does not exist", async () => { + it("throws an error when the signing keys file does not exist", async () => { const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( "failed to read signing keys: ", ); }); - it("throws a descriptive error when the signing keys file is malformed JSON", async () => { + it("throws an error when the signing keys file is malformed JSON", async () => { const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); @@ -4362,167 +1057,4 @@ describe("resolveLocalJwks", () => { ); }); }); - - describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - for (const name of [ - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - "SUPABASE_AUTH_ENABLED", - ]) { - delete process.env[name]; - } - }); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await runLocalJwks( - config, - tempRoot.current, - "a".repeat(32), - undefined, - new Set(["auth.signing_keys_path"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); - }); - - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); - - it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://remote-issuer.example/.well-known/openid-configuration") { - return new Response( - JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - } - if (url === "https://remote-issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = - "https://env-should-not-win.test"; - const config = baseConfig({ - auth: { - third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, - }, - }); - const jwks = await runLocalJwks( - config, - WORKDIR, - "a".repeat(32), - undefined, - new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; - expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); - fetchMock.mockRestore(); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect( - runLocalJwks(config, WORKDIR, "a".repeat(32), undefined, new Set(["auth.enabled"])), - ).resolves.toEqual(expect.any(String)); - }); - - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', - ); - }); - }); -}); - -describe("resolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; - }); - - it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; - const document = { auth: { external_url: "https://remote.test" } }; - expect(resolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( - "https://remote.test", - ); - }); - - it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; - const document = { auth: { external_url: "https://configured.test" } }; - expect(resolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); - }); -}); - -describe("resolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - const tempRoot = useTempWorkdir("supabase-configured-signing-keys-test-"); - - afterEach(() => { - delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; - delete process.env["SUPABASE_AUTH_ENABLED"]; - }); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const keys = resolveConfiguredSigningKeys( - config, - tempRoot.current, - undefined, - new Set(["auth.signing_keys_path"]), - ); - expect(keys).toHaveLength(1); - expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); - }); - - it("still reads the env-overridden path when no remote block matched", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - "failed to read signing keys: ", - ); - }); - - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - resolveConfiguredSigningKeys(config, tempRoot.current, undefined, new Set(["auth.enabled"])), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', - ); - }); }); diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index da86f74813..57ac81dba7 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -1,141 +1,58 @@ -import { - loadCliProjectEnvironment, - CliConfigSchema, - type LoadedCliConfig, - type CliConfig, -} from "@supabase/config/effect"; -import { Config, Crypto, Effect, FileSystem, Option, Path, Schema } from "effect"; +import type { CliConfig } from "@supabase/config"; +import { Crypto, Effect, FileSystem, Option, Path } from "effect"; -import { loadCliConfig } from "./cli-config-load.ts"; -import { recordOrioleDbTelemetry } from "./db-image.ts"; -import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; -import { getHostname } from "./hostname.ts"; -import { envOverride, envOverrideMajorVersion } from "./local-config-values.ts"; -import { resolveProjectEnvironmentValues } from "./project-environment.ts"; +import { CliConfigValues, type ResolvedCliConfig } from "../config/cli-config-values.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { + describeConfigLoadFailure, + loadLocalResolvedConfigContext, +} from "./resolved-config-context.ts"; +import { recordOrioleDbTelemetry } from "./db-image.ts"; -/** Config, resolved project env values, hostname, and sanitized project id for a command. */ +/** Effective config, project env file values, hostname, and sanitized project id for a command. */ export interface LocalProjectContext { + /** The decoded config with every override, default and normalizer applied. */ readonly config: CliConfig; - readonly projectEnvValues: Record; - /** `null` when no `supabase/config.toml` was found. */ - readonly loaded: LoadedCliConfig | null; + /** Values from `supabase/.env*` files only; a name the shell sets is never in here. */ + readonly projectEnvValues: Readonly>; + readonly resolvedConfig: ResolvedCliConfig; readonly hostname: string; - /** Sanitized project id; see {@link sanitizeProjectId}. */ + /** The project id sanitized for Docker resource names. */ readonly projectId: string; } export const loadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, - // An already-resolved `--linked`/`--project-ref` value, when the caller has one; merges the - // matching `[remotes.]` block over the base config. Defaults to `undefined` (no remote - // merge) for callers that don't have one yet. + // An already-resolved `--linked`/`--project-ref` value, when the caller has one; it selects + // the matching `[remotes.]` block. `undefined` applies no remote. projectRef?: string, ): Effect.Effect< LocalProjectContext, E, - FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto + FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto | CliConfigValues > => Effect.gen(function* () { - const supabaseEnv = - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_ENV")).pipe( - Effect.mapError(() => - mapConfigLoadError("failed to resolve environment variable: SUPABASE_ENV"), - ), - ), - ) || "development"; - // `workdir` is already the fully-resolved chdir target, so `search: false` stops - // `@supabase/config` from climbing ancestors and picking up an unrelated project's - // config.toml when `workdir` has none of its own. - const projectEnv = yield* loadCliProjectEnvironment({ - cwd: workdir, - baseEnv: process.env, - search: false, - // Omits `.env.local` when `SUPABASE_ENV=test`, matching - // `resolveProjectEnvironmentValues`'s gating for the project-root pass. - skipEnvLocal: supabaseEnv === "test", - }).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - ); - - // Must resolve before `loadCliConfig` decodes config.toml: an `env(...)`-valued `project_id` - // needs these values available to the decoder already. `workdir` is passed through so dotenv - // files under `/supabase` are still discovered even when `projectEnv` is `null`. - const projectEnvValues = yield* resolveProjectEnvironmentValues( - projectEnv, - workdir, - supabaseEnv, - ).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - ); - - // An absent config.toml is not a failure — a project id still resolves from the workdir - // basename default. Only a malformed file is a hard error. - const loaded = yield* loadCliConfig(workdir, { - cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, - search: false, - // Restricts resolution to `supabase/config.toml`; without this, a workdir with a stray - // `config.json` would be preferred over it. - tomlOnly: true, - projectRef, - }).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - ); - const config = - loaded?.config ?? - (yield* Schema.decodeEffect(CliConfigSchema)({}).pipe( - Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), - )); - const hostname = yield* getHostname().pipe( - Effect.mapError((cause) => - mapConfigLoadError(`failed to resolve hostname: ${cause.message}`), - ), - ); - // When a `[remotes.]` block matched `projectRef` above, its own `project_id` field is - // what selected it, so a stale or differently-scoped `SUPABASE_PROJECT_ID` must not win over - // it here. - const projectId = sanitizeProjectId( - resolveLocalProjectId( - loaded?.appliedRemote !== undefined - ? undefined - : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_PROJECT_ID")).pipe( - Effect.mapError(() => - mapConfigLoadError( - "failed to resolve environment variable: SUPABASE_PROJECT_ID", - ), - ), - ), - )), - config.project_id, - workdir, - projectRef, - ), - ); + const { resolvedConfig, config, projectEnvValues, hostname, projectId } = + yield* loadLocalResolvedConfigContext(workdir, Option.fromNullishOr(projectRef)).pipe( + Effect.mapError((cause) => mapConfigLoadError(describeConfigLoadFailure(cause))), + ); + const appliedRemote = Option.getOrUndefined(resolvedConfig.appliedRemote); yield* Effect.annotateCurrentSpan({ - "config.found": loaded !== null, - "config.remote_applied": loaded?.appliedRemote !== undefined, + "config.remote_applied": appliedRemote !== undefined, }); - return { config, projectEnvValues, loaded, hostname, projectId }; + return { + config, + projectEnvValues, + resolvedConfig, + hostname, + projectId, + }; }).pipe(Effect.withSpan("LocalProjectContext.load")); /** Records OrioleDB selection for commands whose only local config read is this context. */ export const recordLocalProjectOrioleDbTelemetry = (context: LocalProjectContext) => - Effect.try(() => ({ - version: envOverride( - "SUPABASE_DB_ORIOLEDB_VERSION", - context.config.db.orioledb_version, - context.projectEnvValues, - ), - majorVersion: envOverrideMajorVersion( - context.config.db.major_version, - context.projectEnvValues, - ), - })).pipe( - Effect.flatMap(({ version, majorVersion }) => recordOrioleDbTelemetry(version, majorVersion)), + recordOrioleDbTelemetry(context.config.db.orioledb_version, context.config.db.major_version).pipe( Effect.ignore, ); diff --git a/apps/cli/src/command-internal/local-project-context.unit.test.ts b/apps/cli/src/command-internal/local-project-context.unit.test.ts index 8b170f4f92..d9bc7c33d6 100644 --- a/apps/cli/src/command-internal/local-project-context.unit.test.ts +++ b/apps/cli/src/command-internal/local-project-context.unit.test.ts @@ -1,20 +1,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { - Config, - ConfigProvider, - Data, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, -} from "effect"; +import { Config, ConfigProvider, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; -import { useTempWorkdir, withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import { loadLocalProjectContext } from "./local-project-context.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { mockOutput, processEnvLayer } from "../../tests/helpers/mocks.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { sanitizeProjectId } from "../shared/config/project-id.ts"; +import { loadLocalProjectContext } from "./local-project-context.ts"; /** Stands in for the whole Docker-client env-key set, which a project dotenv file never reaches. */ const DOCKER_HOST_KEY = "DOCKER_HOST"; @@ -22,6 +16,8 @@ const DOCKER_HOST_KEY = "DOCKER_HOST"; /** Unlike Docker-client keys, this one is read at container-spawn time, so a project dotenv file can still set it. */ const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; +const REF = "abcdefghijklmnopqrst"; + class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} const writeDotEnv = Effect.fnUntraced(function* (workdir: string, contents: string) { @@ -50,80 +46,89 @@ const processEnvValue = (name: string) => ), ); +/** Runs against a shell environment of exactly `env`, with the real config service. */ +const layerWithShellEnv = (env: Readonly> = {}) => + Layer.fresh( + Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), + ), + ), + ), + processEnvLayer(env), + ), + ); + const tempRoot = useTempWorkdir("supabase-project-context-"); -describe("loadLocalProjectContext", () => { - it.effect( - "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", - () => { - const ref = "abcdefghijklmnopqrst"; - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeConfigToml( - workdir, - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ); +const loadContext = (workdir: string, projectRef?: string) => + loadLocalProjectContext(workdir, (message) => new TestError({ message }), projectRef); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ref, - ); - expect(context.loaded?.appliedRemote).toBe("prod"); - expect(context.projectId).toBe(ref); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "local", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "local" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), +describe("loadLocalProjectContext", () => { + it.effect("prefers SUPABASE_PROJECT_ID over a matched [remotes.]'s project_id", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${REF}"`, ""].join("\n"), ); - }, - ); - it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { - const ref = "abcdefghijklmnopqrst"; + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.some("prod")); + expect(context.projectId).toBe("local"); + }).pipe(Effect.provide(layerWithShellEnv({ SUPABASE_PROJECT_ID: "local" }))); + }); + + it.effect("names the project after the workdir, not the ref, when no project_id is set", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const path = yield* Path.Path; + yield* writeConfigToml(workdir, ""); + + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.none()); + expect(context.projectId).toBe(sanitizeProjectId(path.basename(workdir))); + }).pipe(Effect.provide(layerWithShellEnv())); + }); + + it.effect("applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { const workdir = tempRoot.current; return Effect.gen(function* () { yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ref, - ); - expect(context.loaded?.appliedRemote).toBeUndefined(); + const context = yield* loadContext(workdir, REF); + expect(context.resolvedConfig.appliedRemote).toEqual(Option.none()); expect(context.projectId).toBe("env-project"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "env-project", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "env-project" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv({ SUPABASE_PROJECT_ID: "env-project" }))); }); - it.effect("does NOT install a project .env's DOCKER_HOST into process.env", () => { + it.effect("does not install a project .env's DOCKER_HOST into process.env", () => { const workdir = tempRoot.current; return Effect.gen(function* () { yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); + yield* loadContext(workdir); expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBeUndefined(); - }).pipe( - (body) => withEnvVar(DOCKER_HOST_KEY, undefined, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv())); }); it.effect( - "leaves an already-set shell DOCKER_HOST untouched regardless of a conflicting project .env value", + "leaves an already-set shell DOCKER_HOST untouched by a conflicting project .env", () => { const workdir = tempRoot.current; return Effect.gen(function* () { yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - yield* loadLocalProjectContext(workdir, (message) => new TestError({ message })); + yield* loadContext(workdir); expect(yield* processEnvValue(DOCKER_HOST_KEY)).toBe("tcp://real-shell-host:2375"); }).pipe( - (body) => withEnvVar(DOCKER_HOST_KEY, "tcp://real-shell-host:2375", body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(layerWithShellEnv({ [DOCKER_HOST_KEY]: "tcp://real-shell-host:2375" })), ); }, ); @@ -133,192 +138,40 @@ describe("loadLocalProjectContext", () => { return Effect.gen(function* () { yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); + const context = yield* loadContext(workdir); expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe( "/opt/atlassian/pipelines/agent/build", ); expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBeUndefined(); - }).pipe( - (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, undefined, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("keeps the shell Bitbucket marker ahead of the project value", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); - expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBe("/real-shell-clone-dir"); - }).pipe( - (body) => withEnvVar(BITBUCKET_CLONE_DIR_KEY, "/real-shell-clone-dir", body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("falls back to config.toml's project_id when SUPABASE_PROJECT_ID is empty", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectId).toBe("toml-project"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", "", body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + }).pipe(Effect.provide(layerWithShellEnv())); }); it.effect( - "falls back to config.toml's project_id when only Config sees an empty SUPABASE_PROJECT_ID", + "leaves a shell-set key out of projectEnvValues so the shell value stays the source", () => { - // Windows env lookups are case-insensitive, so a lowercase `supabase_project_id=` reaches the - // Config read with "" while the ambient values only carry the lowercase key. const workdir = tempRoot.current; return Effect.gen(function* () { - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectId).toBe("toml-project"); + const context = yield* loadContext(workdir); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBeUndefined(); + expect(yield* processEnvValue(BITBUCKET_CLONE_DIR_KEY)).toBe("/real-shell-clone-dir"); }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), - (body) => withConfigEnv({ SUPABASE_PROJECT_ID: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(layerWithShellEnv({ [BITBUCKET_CLONE_DIR_KEY]: "/real-shell-clone-dir" })), ); }, ); - it.effect("loads the development dotenv files when SUPABASE_ENV is empty", () => { + it.effect("holds only project .env file values, never host environment variables", () => { const workdir = tempRoot.current; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString( - path.join(workdir, "supabase", ".env.development"), - "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", - ); - - const context = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ); - expect(context.projectEnvValues["SUPABASE_AUTH_JWT_SECRET"]).toBe("dev-secret"); - }).pipe( - (body) => withEnvVar("SUPABASE_AUTH_JWT_SECRET", undefined, body), - (body) => withConfigEnv({ SUPABASE_ENV: "" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("skips an unparseable supabase/.env.local when SUPABASE_ENV is test", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); - - const exit = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }).pipe( - (body) => withConfigEnv({ SUPABASE_ENV: "test" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("fails on an unparseable supabase/.env.local outside the test environment", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); - yield* fs.writeFileString(path.join(workdir, "supabase", ".env.local"), "not a valid line\n"); - - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error.message).toMatch(/^failed to read config: /); - }).pipe( - (body) => withConfigEnv({ SUPABASE_ENV: "development" }, body), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); + yield* writeConfigToml(workdir, ""); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), "FROM_FILE=file-value\n"); - it.effect("keeps the Error: prefix when a project-root .env is malformed", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - yield* writeDotEnv(workdir, "not a valid line\n"); - - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error.message).toMatch( - /^failed to read config: Error: failed to parse environment file: /, - ); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))); - }); - - it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TestError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); - }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.make(() => - Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), - ), - ), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }); - - it.effect("fails with a typed error when SUPABASE_PROJECT_ID cannot be resolved", () => { - const workdir = tempRoot.current; - return Effect.gen(function* () { - const error = yield* loadLocalProjectContext( - workdir, - (message) => new TestError({ message }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TestError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_PROJECT_ID"); - }).pipe( - (body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.make((path) => - path[0] === "SUPABASE_PROJECT_ID" - ? Effect.fail(new ConfigProvider.SourceError({ message: "injected" })) - : ConfigProvider.fromEnvRecord({}).load(path), - ), - ), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); + const context = yield* loadContext(workdir); + expect(context.projectEnvValues).toEqual({ FROM_FILE: "file-value" }); + }).pipe(Effect.provide(layerWithShellEnv({ FROM_SHELL: "shell-value" }))); }); }); diff --git a/apps/cli/src/command-internal/management-api-runtime.layer.ts b/apps/cli/src/command-internal/management-api-runtime.layer.ts index 1340d805c3..419311f577 100644 --- a/apps/cli/src/command-internal/management-api-runtime.layer.ts +++ b/apps/cli/src/command-internal/management-api-runtime.layer.ts @@ -15,7 +15,7 @@ import { CommandSettings } from "../config/command-settings.service.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { projectRefLayer } from "../config/project-ref.layer.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { dohFetchLayer } from "./http-dns.ts"; import { IdentityStitch, identityStitchLayer } from "./identity-stitch.ts"; diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 25331aae58..b93c51b7da 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -1,5 +1,6 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; +import { envValue } from "../shared/config/env-option.ts"; import { Output } from "../shared/output/output.service.ts"; import { bold } from "./colors.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; @@ -263,50 +264,50 @@ export const checkScannerBufferSize = ( content: string, mapError: (message: string, phase: "read" | "exec") => E, projectEnv: Readonly> = {}, -): Effect.Effect => { - const raw = - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; - if (raw === undefined) return Effect.void; - const configuredLimit = parseScannerBufferSize(raw); - // Covers both an explicit non-positive size and an unparseable value (see - // `DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not - // to "no limit". - const limit = - configuredLimit > 0 - ? Math.max(configuredLimit, SCANNER_START_BUF_SIZE) - : DEFAULT_MAX_SCANNER_CAPACITY; - // The reported limit is the raw configured value, even below the `SCANNER_START_BUF_SIZE` - // floor (which only affects when the too-long error can fire, not the number reported), or the - // hardcoded default once that's been fallen back to. - const reportedLimit = configuredLimit > 0 ? configuredLimit : DEFAULT_MAX_SCANNER_CAPACITY; - let emitted = 0; - let lastRaw = ""; - for (const token of splitSqlTokens(content)) { - // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an - // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter left - // to find once the buffer fills without one. - const tooLong = token.terminated - ? utf8ByteLength(token.raw) > limit - : utf8ByteLength(token.raw) >= limit; - if (tooLong) { - const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; - return Effect.fail( - mapError( - `scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, - "read", - ), - ); - } - // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance - // `emitted` — so a lone `;` right before an oversized statement reports it accurately instead - // of a blank token. - lastRaw = token.raw; - if (token.trimmed.length > 0) { - emitted += 1; +): Effect.Effect => + Effect.gen(function* () { + const shell = yield* envValue("SUPABASE_SCANNER_BUFFER_SIZE"); + const raw = shell ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; + if (raw === undefined) return; + const configuredLimit = parseScannerBufferSize(raw); + // Covers both an explicit non-positive size and an unparseable value (see + // `DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not + // to "no limit". + const limit = + configuredLimit > 0 + ? Math.max(configuredLimit, SCANNER_START_BUF_SIZE) + : DEFAULT_MAX_SCANNER_CAPACITY; + // The reported limit is the raw configured value, even below the `SCANNER_START_BUF_SIZE` + // floor (which only affects when the too-long error can fire, not the number reported), or the + // hardcoded default once that's been fallen back to. + const reportedLimit = configuredLimit > 0 ? configuredLimit : DEFAULT_MAX_SCANNER_CAPACITY; + let emitted = 0; + let lastRaw = ""; + for (const token of splitSqlTokens(content)) { + // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an + // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter left + // to find once the buffer fills without one. + const tooLong = token.terminated + ? utf8ByteLength(token.raw) > limit + : utf8ByteLength(token.raw) >= limit; + if (tooLong) { + const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; + return yield* Effect.fail( + mapError( + `scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, + "read", + ), + ); + } + // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance + // `emitted` — so a lone `;` right before an oversized statement reports it accurately instead + // of a blank token. + lastRaw = token.raw; + if (token.trimmed.length > 0) { + emitted += 1; + } } - } - return Effect.void; -}; + }); /** * Renders a `^` caret line under the error position of a failing statement. `pos` is the diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index 552fc8e478..f9f1cab291 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { Data, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { @@ -11,6 +11,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import type { DbBatchStatement, DbSession } from "./db-connection.service.ts"; import { @@ -1224,7 +1225,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1265,7 +1266,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1343,7 +1344,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1382,7 +1383,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }); @@ -1420,7 +1421,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }); @@ -1455,7 +1456,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1498,7 +1499,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1534,7 +1535,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }, ); @@ -1611,7 +1612,7 @@ describe("applySchemaFiles", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), ); }); }); diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 0390201928..0f5cf8302d 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -8,7 +8,7 @@ import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { bold } from "./colors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { DbConnection } from "./db-connection.service.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; +import { loadProjectEnvValues } from "./db-config.toml-read.ts"; import { type DbTargetSelection } from "./db-target-flags.ts"; import { readMigrationTable } from "./migration-history.ts"; import { LinkedProjectCache } from "../telemetry/linked-project-cache.service.ts"; @@ -87,7 +87,7 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( // SUPABASE_YES set only in supabase/.env auto-confirms, but a flag conflict still // surfaces before any .env read. Resolve --yes against the project env here, not // just process.env. Same ordering as `migration down`/`repair`. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); // Linked fetch caches the project ref on success. The ref is diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 751f259c44..e87050ea85 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -2,7 +2,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; -import { readProjectRefFile, tempPaths } from "./temp-paths.ts"; +import { readProjectRefFile, tempPaths } from "../shared/config/temp-paths.ts"; export type ParentRefResolution = | { readonly kind: "resolved"; readonly ref: string } diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index 0523e8f2b6..3cd9a010b7 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -10,9 +10,9 @@ import { homedir } from "node:os"; -import type { Path } from "effect"; +import { Effect, type Path } from "effect"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; +import { readSupabaseHome } from "../shared/config/supabase-home.ts"; /** `supabase/.temp/pgdelta` — catalog snapshots and debug bundles. */ export function pgDeltaTempPath(path: Path.Path, workdir: string): string { @@ -22,14 +22,8 @@ export function pgDeltaTempPath(path: Path.Path, workdir: string): string { /** * Global shadow-baseline cache directory: * `${SUPABASE_HOME}/cache/shadow-baseline` (default `~/.supabase/cache/shadow-baseline`). - * - * Pure: callers may pass `env`/`homeDir` for tests; production uses `process.env` and - * `os.homedir()`. */ -export function shadowBaselineCacheDir( - path: Path.Path, - env: Readonly> = process.env, - homeDir: string = homedir(), -): string { - return path.join(resolveSupabaseHome(path, env, homeDir), "cache", "shadow-baseline"); -} +export const shadowBaselineCacheDir = (path: Path.Path, homeDir: string = homedir()) => + Effect.map(readSupabaseHome(path, homeDir), (home) => + path.join(home, "cache", "shadow-baseline"), + ); diff --git a/apps/cli/src/command-internal/pgdelta.ts b/apps/cli/src/command-internal/pgdelta.ts index 350fff55ba..2e8e35090a 100644 --- a/apps/cli/src/command-internal/pgdelta.ts +++ b/apps/cli/src/command-internal/pgdelta.ts @@ -1,6 +1,6 @@ -import { Option } from "effect"; +import { Effect } from "effect"; -import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; +import { envValue } from "../shared/config/env-option.ts"; /** * Ambient inputs shared by the pg-delta and migra diff workflows: the project id @@ -20,30 +20,6 @@ export interface PgDeltaContext { readonly projectEnv: Readonly>; } -/** - * Resolves {@link PgDeltaContext.projectId} — not `CommandSettings.projectId` alone, which is - * env-only and resolves to `""` for a project relying on config.toml's `project_id` or the - * workdir-basename default, mounting the wrong `supabase_edge_runtime_` Deno-cache volume. - * Shared by every pg-delta context builder (`db diff`, `db pull`, declarative generate/sync). - * - * When a matched `[remotes.]` block already resolved its own `project_id` into - * `toml.projectId`, the raw `cliProjectId` argument is suppressed entirely — otherwise an - * unrelated ambient `SUPABASE_PROJECT_ID` could win back over the matched remote's id. - */ -export function resolvePgDeltaProjectId( - cliProjectId: Option.Option, - toml: { readonly projectId: Option.Option; readonly appliedRemote: string | undefined }, - workdir: string, -): string { - return sanitizeProjectId( - resolveLocalProjectId( - toml.appliedRemote !== undefined ? undefined : Option.getOrUndefined(cliProjectId), - Option.getOrUndefined(toml.projectId), - workdir, - ), - ); -} - export function isPostgresURL(ref: string): boolean { return ref.startsWith("postgres://") || ref.startsWith("postgresql://"); } @@ -53,7 +29,10 @@ export function edgeRuntimeId(projectId: string): string { } /** Recognizes `PGDELTA_DEBUG=1`/`true`/`yes` (case-insensitive). */ -export function isPgDeltaDebugEnabled(): boolean { - const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes"; -} +export const isPgDeltaDebugEnabled: Effect.Effect = Effect.map( + envValue("PGDELTA_DEBUG"), + (raw) => { + const value = (raw ?? "").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; + }, +); diff --git a/apps/cli/src/command-internal/pgdelta.unit.test.ts b/apps/cli/src/command-internal/pgdelta.unit.test.ts index 7992e609c1..2546d3f124 100644 --- a/apps/cli/src/command-internal/pgdelta.unit.test.ts +++ b/apps/cli/src/command-internal/pgdelta.unit.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { edgeRuntimeId, isPgDeltaDebugEnabled, isPostgresURL } from "./pgdelta.ts"; describe("isPostgresURL", () => { @@ -18,23 +20,18 @@ describe("edgeRuntimeId", () => { }); describe("isPgDeltaDebugEnabled", () => { - const prev = process.env["PGDELTA_DEBUG"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - }); - - it("is true for 1/true/yes (case-insensitive, trimmed)", () => { - for (const value of ["1", "true", "YES", " True "]) { - process.env["PGDELTA_DEBUG"] = value; - expect(isPgDeltaDebugEnabled()).toBe(true); - } - }); + it.effect("is true for 1/true/yes (case-insensitive, trimmed)", () => + Effect.gen(function* () { + for (const value of ["1", "true", "YES", " True "]) { + const debug = yield* withConfigEnv({ PGDELTA_DEBUG: value }, isPgDeltaDebugEnabled); + expect(debug).toBe(true); + } + }), + ); - it("is false otherwise", () => { - process.env["PGDELTA_DEBUG"] = "0"; - expect(isPgDeltaDebugEnabled()).toBe(false); - delete process.env["PGDELTA_DEBUG"]; - expect(isPgDeltaDebugEnabled()).toBe(false); - }); + it.effect("is false otherwise", () => + Effect.gen(function* () { + expect(yield* withConfigEnv({ PGDELTA_DEBUG: "0" }, isPgDeltaDebugEnabled)).toBe(false); + }), + ); }); diff --git a/apps/cli/src/command-internal/project-environment.ts b/apps/cli/src/command-internal/project-environment.ts deleted file mode 100644 index ebccf2a310..0000000000 --- a/apps/cli/src/command-internal/project-environment.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { CliProjectEnvironment } from "@supabase/config"; -import { Config, Data, Effect, FileSystem, Option, Path } from "effect"; - -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityFingerprintId, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; -import { parseDotEnv } from "./dotenv.ts"; - -/** - * Dotenv filenames to check, in precedence order, for a given `SUPABASE_ENV` value - * (`.env..local`, `.env.local` — skipped for `"test"` — `.env.`, `.env`). - * - * Fills a gap in `@supabase/config`'s `loadCliProjectEnvironment`, which only resolves this - * order for the `supabase/` directory: this module also checks the project root, and applies - * `SUPABASE_ENV`-selected filenames there too. Its own resolved values still take precedence - * over anything found here, since it's already correct for the keys it knows about. - */ -export function candidateDotenvFilenames(env: string): ReadonlyArray { - return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; -} - -export class ProjectEnvironmentError extends Data.Error<{ readonly message: string }> { - static readonly [ErrorActionabilityFingerprintId] = "ProjectEnvironmentError"; - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -/** - * Reads and parses a dotenv file, or `undefined` if it doesn't exist. Delegates to - * {@link parseDotEnv} rather than a hand-rolled line scanner, so a quoted value spanning - * physical lines (a PEM/private key) parses correctly. - * - * Fails on a malformed line (not blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` - * assignment) — the caller must fail rather than silently skip it. - */ -const readDotEnvFile = Effect.fnUntraced(function* (path: string) { - const fs = yield* FileSystem.FileSystem; - if (!(yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)))) return undefined; - - const bytes = yield* fs.readFile(path).pipe( - Effect.mapError( - ({ reason }) => - new ProjectEnvironmentError({ - message: reason.cause instanceof Error ? reason.cause.message : reason.message, - }), - ), - ); - const contents = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); - return yield* Effect.try({ - try: () => parseDotEnv(contents), - catch: (cause) => - new ProjectEnvironmentError({ - message: `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, - }), - }); -}); - -/** - * Merged env-var map for `stop`/`status` to read `SUPABASE_*` overrides from, covering the - * project-root and `SUPABASE_ENV`-selected files {@link candidateDotenvFilenames} adds beyond - * `loadCliProjectEnvironment`. - * - * Only `projectEnv`'s ambient-sourced entries are layered back on top: its other, file-derived - * entries aren't necessarily higher-precedence than the env-specific files resolved here, and - * it has no notion of those filenames to tell the two apart itself. - * - * `projectEnv` is `null` when no config file was found, but dotenv loading isn't gated on - * that — fall back to deriving `/supabase` and `workdir` directly, with `process.env` - * as the ambient layer. - */ -export const resolveProjectEnvironmentValues = Effect.fn("ProjectEnvironment.resolve")(function* ( - projectEnv: CliProjectEnvironment | null, - workdir: string, - supabaseEnv?: string, -) { - const path = yield* Path.Path; - const env = - supabaseEnv || - Option.getOrUndefined( - yield* Config.option(Config.String("SUPABASE_ENV")).pipe( - Effect.mapError( - () => - new ProjectEnvironmentError({ - message: "failed to resolve environment variable: SUPABASE_ENV", - }), - ), - ), - ) || - "development"; - const filenames = [...new Set(candidateDotenvFilenames(env))]; - const merged: Record = {}; - let fileCount = 0; - - const supabaseDir = projectEnv?.paths.supabaseDir ?? path.join(workdir, "supabase"); - const projectRoot = projectEnv?.paths.projectRoot ?? workdir; - - // supabase/ dir first, then its parent (the project root). Within a directory, - // "never override an already-set var" means first-processed-wins, so skipping keys - // already present reproduces both orderings at once. - for (const dir of [supabaseDir, projectRoot]) { - for (const filename of filenames) { - const parsed = yield* readDotEnvFile(path.join(dir, filename)); - if (parsed === undefined) continue; - fileCount += 1; - for (const [key, value] of Object.entries(parsed)) { - if (!(key in merged)) merged[key] = value; - } - } - } - yield* Effect.annotateCurrentSpan("file.count", fileCount); - - 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/command-internal/project-environment.unit.test.ts b/apps/cli/src/command-internal/project-environment.unit.test.ts deleted file mode 100644 index 6219fbd387..0000000000 --- a/apps/cli/src/command-internal/project-environment.unit.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, layer } from "@effect/vitest"; -import type { CliProjectEnvironment } from "@supabase/config"; -import { ConfigProvider, Effect, FileSystem, Path, Tracer } from "effect"; - -import { withConfigEnv, withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import { ProjectEnvironmentError, resolveProjectEnvironmentValues } from "./project-environment.ts"; - -const project = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-project-env-" }); - const supabaseDir = path.join(root, "supabase"); - yield* fs.makeDirectory(supabaseDir, { recursive: true }); - return { fs, path, root, supabaseDir }; -}); - -function fakeProjectEnv( - { path, root, supabaseDir }: Effect.Success, - values: Record = {}, - sources: Record = {}, -): CliProjectEnvironment { - return { - paths: { - projectRoot: root, - supabaseDir, - configPath: path.join(supabaseDir, "config.toml"), - envPath: path.join(supabaseDir, ".env"), - envLocalPath: path.join(supabaseDir, ".env.local"), - }, - values, - loadedPaths: [], - // Defaults each value's source to "ambient" so callers don't need to spell it out. - sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), - }; -} - -layer(BunServices.layer)("resolveProjectEnvironmentValues", (it) => { - it.effect("returns just the already-loaded values when no extra dotenv files exist", () => - Effect.gen(function* () { - const dirs = yield* project; - const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "from-loader" }); - expect(yield* resolveProjectEnvironmentValues(projectEnv, dirs.root)).toEqual({ - SUPABASE_PROJECT_ID: "from-loader", - }); - }), - ); - - it.effect("fills in a value from a project-root .env file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); - }), - ); - - it.effect("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }), - ); - - it.effect("lets already-resolved projectEnv.values win over anything discovered locally", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const projectEnv = fakeProjectEnv(dirs, { SUPABASE_PROJECT_ID: "ambient-project" }); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }), - ); - - it.effect("defaults SUPABASE_ENV to development when unset", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_PROJECT_ID=dev-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), - ); - - it.effect("defaults an empty SUPABASE_ENV to development", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_PROJECT_ID=dev-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "" }, body)), - ); - - it.effect("selects the SUPABASE_ENV-named file over the bare .env file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env.production.local"), - "SUPABASE_PROJECT_ID=prod-local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("skips .env.local when SUPABASE_ENV=test", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - yield* fs.writeFileString(path.join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "test" }, body)), - ); - - it.effect("prefers an explicit supabaseEnv argument over the configured SUPABASE_ENV", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root, "test"); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("bare-env-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("falls back to the configured SUPABASE_ENV when no supabaseEnv argument is given", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - yield* fs.writeFileString( - path.join(root, ".env.production"), - "SUPABASE_PROJECT_ID=prod-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "production" }, body)), - ); - - it.effect("strips quotes the same way the shared dotenv parser does", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); - }), - ); - - it.effect("ignores blank lines and comments", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); - }), - ); - - it.effect("preserves a literal # in an unquoted value with no leading whitespace", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); - }), - ); - - it.effect("still truncates an unquoted value at a whitespace-preceded inline comment", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=54323 # local\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); - }), - ); - - it.effect("strips a trailing comment after a quoted value", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_PROJECT_ID="demo" # local\n'); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }), - ); - - it.effect("accepts a colon-separated assignment (YAML-style key/value form)", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID: colon-project\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); - }), - ); - - it.effect( - "prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", - () => - Effect.gen(function* () { - // Only an "ambient" source outranks the file precedence computed locally. - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - dirs, - { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, - { SUPABASE_PROJECT_ID: ".env" }, - ); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), - ); - - it.effect("still lets a truly ambient-sourced value win over any file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - dirs, - { SUPABASE_PROJECT_ID: "ambient-project" }, - { SUPABASE_PROJECT_ID: "ambient" }, - ); - const merged = yield* resolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }).pipe((body) => withConfigEnv({ SUPABASE_ENV: "development" }, body)), - ); - - it.effect("fails on a malformed line", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "not a valid line\n"); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(ProjectEnvironmentError); - expect(String(error)).toMatch(/^Error: failed to parse environment file: /); - }), - ); - - it.effect("keeps a leading BOM, so a BOM-prefixed .env fails to parse", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "\u{FEFF}SUPABASE_PROJECT_ID=bom-project\n", - ); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(String(error)).toMatch(/^Error: failed to parse environment file: /); - }), - ); - - it.effect("fails with the native read error when a dotenv path is a directory", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.makeDirectory(path.join(root, ".env")); - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.flip, - ); - expect(String(error)).toMatch(/^Error: EISDIR/); - }), - ); - - it.effect("fails with a typed error when SUPABASE_ENV cannot be resolved", () => - Effect.gen(function* () { - const dirs = yield* project; - const error = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), dirs.root).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(ProjectEnvironmentError); - expect(error.message).toBe("failed to resolve environment variable: SUPABASE_ENV"); - }).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.make(() => - Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), - ), - ), - ), - ), - ); - - it.effect("skips a dotenv path that is a symlink loop, like a missing file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.symlink(path.join(root, ".env"), path.join(root, ".env")); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged).toEqual({}); - }), - ); - - it.effect("expands an unquoted $VAR reference to an earlier value in the same file", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }), - ); - - it.effect("expands a braced ${VAR} reference in a double-quoted value", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); - }), - ); - - it.effect("does not expand variable references inside single-quoted values", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); - }), - ); - - it.effect("expands an unresolved bare reference to an empty string", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); - }), - ); - - it.effect("expands an unresolved braced reference to an empty string", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); - }), - ); - - it.effect("preserves a backslash-escaped $VAR reference as a literal", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n", - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); - }), - ); - - it.effect("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n', - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); - }), - ); - - it.effect("treats a bare trailing $ with no variable name as a literal", () => - Effect.gen(function* () { - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString(path.join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); - }), - ); - - it.effect("preserves a multiline quoted value alongside an unrelated SUPABASE_* key", () => - Effect.gen(function* () { - // A quoted value spanning physical lines (e.g. a pasted PEM key) must not break - // parsing of the rest of the file. - const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; - const dirs = yield* project; - const { fs, path, root } = dirs; - yield* fs.writeFileString( - path.join(root, ".env"), - `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, - ); - const merged = yield* resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); - }), - ); - - it.effect( - "records the parsed dotenv file count on one resolve span, and no span when tracing is off", - () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env.development"), - "SUPABASE_AUTH_JWT_SECRET=dev-secret\n", - ); - const resolve = resolveProjectEnvironmentValues(fakeProjectEnv(dirs), root).pipe( - Effect.withTracer(tracer), - ); - const expected = { - SUPABASE_PROJECT_ID: "supabase-dir-project", - SUPABASE_AUTH_JWT_SECRET: "dev-secret", - }; - - expect(yield* resolve.pipe(Effect.withTracerEnabled(false))).toEqual(expected); - expect(spans).toEqual([]); - - expect(yield* resolve.pipe(Effect.withTracerEnabled(true))).toEqual(expected); - expect(spans.map((span) => span.name)).toEqual(["ProjectEnvironment.resolve"]); - expect(spans[0]?.attributes.get("file.count")).toBe(2); - }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({})))), - ); - - it.effect("counts a .env.local file once when SUPABASE_ENV is local", () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const dirs = yield* project; - const { fs, path, root, supabaseDir } = dirs; - yield* fs.writeFileString( - path.join(supabaseDir, ".env.local"), - "SUPABASE_PROJECT_ID=local-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues( - fakeProjectEnv(dirs), - root, - "local", - ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("local-project"); - expect(spans[0]?.attributes.get("file.count")).toBe(1); - }), - ); - - describe("when no project was found (projectEnv is null)", () => { - // A missing config.toml must not skip dotenv loading; these cover the fallback - // that derives `/supabase` directly. - - it.effect("still reads a supabase/-dir dotenv file directly under workdir", () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect("still reads a project-root dotenv file directly under workdir", () => - Effect.gen(function* () { - const { fs, path, root } = yield* project; - yield* fs.writeFileString( - path.join(root, ".env"), - "SUPABASE_PROJECT_ID=root-fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect( - "prefers the supabase/-dir file over the project-root file, same as the non-null case", - () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=supabase-dir-project\n", - ); - yield* fs.writeFileString( - path.join(root, ".env"), - "SUPABASE_PROJECT_ID=root-dir-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - - it.effect("lets an ambient shell var win over a dotenv value, using process.env directly", () => - Effect.gen(function* () { - const { fs, path, root, supabaseDir } = yield* project; - yield* fs.writeFileString( - path.join(supabaseDir, ".env"), - "SUPABASE_PROJECT_ID=dotenv-fallback-project\n", - ); - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", "ambient-fallback-project", body)), - ); - - it.effect("returns an empty object when workdir has no dotenv files and no ambient value", () => - Effect.gen(function* () { - const { root } = yield* project; - const merged = yield* resolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); - }).pipe((body) => withEnvVar("SUPABASE_PROJECT_ID", undefined, body)), - ); - }); -}); diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index d50fc1e741..749d10b056 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -21,6 +21,15 @@ export const parseYesNo = (input: string): boolean | undefined => { return undefined; }; +interface YesNoOutcome { + readonly value: boolean; + /** `false` when the default was taken because nothing answered (EOF, timeout, no prompt). */ + readonly answered: boolean; +} + +const answered = (value: boolean): YesNoOutcome => ({ value, answered: true }); +const unanswered = (value: boolean): YesNoOutcome => ({ value, answered: false }); + /** * Confirm-or-default prompt shared by command handlers and shell-agnostic code alike. * `yes` echoes an affirmative answer and returns `true` immediately; non-text output @@ -30,7 +39,7 @@ export const parseYesNo = (input: string): boolean | undefined => { * wins and an empty line takes the default. Any other line declines, except under * `interactive: false`, where it takes the default. */ -export const promptYesNo = Effect.fnUntraced(function* ( +export const promptYesNoOutcome = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, label: string, @@ -41,14 +50,14 @@ export const promptYesNo = Effect.fnUntraced(function* ( const choices = defaultValue ? "Y/n" : "y/N"; if (yes) { yield* output.raw(`${label} [${choices}] y\n`, "stderr"); - return true; + return answered(true); } if (output.format !== "text" && !options.readMachineStdin) { - return defaultValue; + return unanswered(defaultValue); } const tty = yield* Tty; if (output.format !== "text" && (!interactive || tty.stdinIsTty)) { - return defaultValue; + return unanswered(defaultValue); } // Text `interactive: false` still prints the label and reads one line instead of // silently returning the default — it uses the same non-TTY read path below. @@ -60,9 +69,15 @@ export const promptYesNo = Effect.fnUntraced(function* ( yield* output.raw(`${input.trim()}\n`, "stderr"); // An unrecognised answer is never consent; under `interactive: false` the line may be // the caller's own script text, so it keeps the default. - return parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); + const value = parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); + return Option.isSome(line) ? answered(value) : unanswered(value); } - return yield* output - .promptConfirm(label, { defaultValue }) - .pipe(Effect.catchTag("NonInteractiveError", () => Effect.succeed(defaultValue))); + return yield* output.promptConfirm(label, { defaultValue }).pipe( + Effect.map(answered), + Effect.catchTag("NonInteractiveError", () => Effect.succeed(unanswered(defaultValue))), + ); }); + +/** {@link promptYesNoOutcome} reduced to the boolean every other caller wants. */ +export const promptYesNo = (...args: Parameters) => + promptYesNoOutcome(...args).pipe(Effect.map((outcome) => outcome.value)); diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 230e9b380d..f220a1925c 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -5,7 +5,7 @@ import { mockOutput, mockStdin, mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; import { Stdin } from "../shared/runtime/stdin.service.ts"; -import { parseYesNo, promptYesNo } from "./prompt-yes-no.ts"; +import { parseYesNo, promptYesNo, promptYesNoOutcome } from "./prompt-yes-no.ts"; describe("parseYesNo", () => { it("parses affirmative answers (case-insensitive, trimmed)", () => { @@ -108,6 +108,36 @@ describe("promptYesNo machine consent", () => { } }); +describe("promptYesNoOutcome piped answers", () => { + const outcome = (piped: string) => + Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNoOutcome(output, false, "Confirm?", false); + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, piped), + mockTty({ stdinIsTty: false }), + ), + ), + ), + ); + + it("reports closed stdin as unanswered, taking the default", async () => { + expect(await outcome("")).toEqual({ value: false, answered: false }); + }); + + it.each([ + ["\n", false], + ["n\n", false], + ["y\n", true], + ])("reports the piped line %j as an answer", async (piped, value) => { + expect(await outcome(piped)).toEqual({ value, answered: true }); + }); +}); + describe("promptYesNo piped text answers", () => { const ask = (piped: string, defaultValue: boolean, interactive = true) => Effect.runPromise( diff --git a/apps/cli/src/command-internal/resolve-token.unit.test.ts b/apps/cli/src/command-internal/resolve-token.unit.test.ts index 8e2cb29fd1..e52ce3b99c 100644 --- a/apps/cli/src/command-internal/resolve-token.unit.test.ts +++ b/apps/cli/src/command-internal/resolve-token.unit.test.ts @@ -20,7 +20,6 @@ const settings = (accessToken: Option.Option>) => workdir: "/tmp/supabase-cli-resolve-token", explicitWorkdir: false, workdirEnvValue: Option.none(), - dbPassword: Option.none(), githubToken: Option.none(), userAgent: "SupabaseCLI/test", }); diff --git a/apps/cli/src/command-internal/resolved-config-context.ts b/apps/cli/src/command-internal/resolved-config-context.ts new file mode 100644 index 0000000000..a54d933595 --- /dev/null +++ b/apps/cli/src/command-internal/resolved-config-context.ts @@ -0,0 +1,81 @@ +import type { CliConfig } from "@supabase/config"; +import { Effect, Option } from "effect"; + +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; +import { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; +import { getHostname } from "./hostname.ts"; + +/** The resolved config with the materialized config, project env values and declared document its readers take. */ +interface ResolvedConfigContext { + readonly resolvedConfig: ResolvedCliConfig; + readonly config: CliConfig; + readonly projectEnvValues: Readonly>; + readonly document: Record | undefined; +} + +/** Loads the resolved config for a command that targets no project, so no `[remotes.*]` block applies. */ +export const loadResolvedConfigContext = Effect.fn("ResolvedConfigContext.load")(function* ( + workdir: string, + projectRef: Option.Option = Option.none(), +) { + const values = yield* CliConfigValues; + const resolvedConfig = yield* values.load({ workdir, projectRef }); + return { + resolvedConfig, + config: resolvedConfig.materialized.config, + projectEnvValues: resolvedConfig.projectEnvValues, + document: resolvedConfig.loaded.document, + } satisfies ResolvedConfigContext; +}); + +/** A {@link ResolvedConfigContext} plus the machine hostname and sanitized project id local Docker naming needs. */ +export interface LocalResolvedConfigContext extends ResolvedConfigContext { + readonly hostname: string; + readonly projectId: string; +} + +export const loadLocalResolvedConfigContext = Effect.fn("LocalResolvedConfigContext.load")( + function* (workdir: string, projectRef: Option.Option = Option.none()) { + const context = yield* loadResolvedConfigContext(workdir, projectRef); + const hostname = yield* getHostname(context.resolvedConfig).pipe( + Effect.mapError( + (cause) => + new CliConfigLoadError({ message: `failed to resolve hostname: ${cause.message}` }), + ), + ); + const projectId = (yield* context.resolvedConfig.get(CliConfigKeys.projectId)).value; + return { ...context, hostname, projectId } satisfies LocalResolvedConfigContext; + }, +); + +/** `auth.passkey` and `auth.webauthn` exist only when their tables do; an env override never creates one. */ +export const resolvePasskeyWebauthn = Effect.fn("ResolvedConfigContext.passkeyWebauthn")(function* ( + resolvedConfig: ResolvedCliConfig, +) { + const passkeyEnabled = resolvedConfig.declares("auth.passkey") + ? (yield* resolvedConfig.get(CliConfigKeys.auth.passkey.enabled)).value + : undefined; + const webauthn = resolvedConfig.declares("auth.webauthn") + ? { + rpId: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpId)).value, + rpDisplayName: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpDisplayName)).value, + rpOrigins: (yield* resolvedConfig.get(CliConfigKeys.auth.webauthn.rpOrigins)).value, + } + : undefined; + return { passkeyEnabled, webauthn }; +}); + +/** The failure text commands report when the resolved config cannot be loaded. */ +export const describeConfigLoadFailure = (cause: unknown): string => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause._tag === "CliConfigValueError" || + cause._tag === "CliConfigFlagConflictError" || + cause._tag === "CliConfigLoadError") && + "message" in cause && + typeof cause.message === "string" + ? cause.message + : `failed to read config: ${String(cause)}`; diff --git a/apps/cli/src/command-internal/resolved-config-context.unit.test.ts b/apps/cli/src/command-internal/resolved-config-context.unit.test.ts new file mode 100644 index 0000000000..a92a544a2f --- /dev/null +++ b/apps/cli/src/command-internal/resolved-config-context.unit.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { mockOutput, processEnvLayer } from "../../tests/helpers/mocks.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { loadLocalResolvedConfigContext } from "./resolved-config-context.ts"; + +const tempRoot = useTempWorkdir("supabase-resolved-config-context-"); + +function writeProjectEnv(workdir: string, contents: string): void { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, ".env"), contents); +} + +/** Runs against a shell environment of exactly `env`, with the real config service and an empty Docker config dir. */ +const layerWithShellEnv = (workdir: string, env: Readonly> = {}) => + Layer.fresh( + Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), + ), + ), + ), + processEnvLayer({ DOCKER_CONFIG: join(workdir, "docker-config"), ...env }), + ), + ); + +describe("loadLocalResolvedConfigContext hostname", () => { + it.effect("reads SUPABASE_SERVICES_HOSTNAME from a project .env", () => { + const workdir = tempRoot.current; + writeProjectEnv(workdir, "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n"); + + return loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("dotenv-host"); + }), + Effect.provide(layerWithShellEnv(workdir)), + ); + }); + + it.effect("prefers a shell SUPABASE_SERVICES_HOSTNAME over a project .env", () => { + const workdir = tempRoot.current; + writeProjectEnv(workdir, "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n"); + + return loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("shell-host"); + }), + Effect.provide(layerWithShellEnv(workdir, { SUPABASE_SERVICES_HOSTNAME: "shell-host" })), + ); + }); + + it.effect("ignores a project .env DOCKER_HOST when discovering the daemon", () => { + const workdir = tempRoot.current; + writeProjectEnv(workdir, "DOCKER_HOST=tcp://x:2375\n"); + + return loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("127.0.0.1"); + }), + Effect.provide(layerWithShellEnv(workdir)), + ); + }); + + it.effect("takes the daemon host from a shell DOCKER_HOST", () => { + const workdir = tempRoot.current; + writeProjectEnv(workdir, "DOCKER_HOST=tcp://project-dotenv-host:2375\n"); + + return loadLocalResolvedConfigContext(workdir).pipe( + Effect.map((context) => { + expect(context.hostname).toBe("shell-daemon-host"); + }), + Effect.provide(layerWithShellEnv(workdir, { DOCKER_HOST: "tcp://shell-daemon-host:2375" })), + ); + }); +}); diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index f0cab9be98..2bf5e6594d 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -1,7 +1,6 @@ import { type CliConfig, CliConfigSchema } from "@supabase/config/effect"; -import { loadCliConfig, type CliConfigLoadOptions } from "./cli-config-load.ts"; import { BunPath } from "@effect/platform-bun"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; import type { PlatformError } from "effect/PlatformError"; @@ -9,8 +8,9 @@ import { Output } from "../shared/output/output.service.ts"; import { resolveYesWithProjectEnv } from "./global-flags.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { bold, yellow } from "./colors.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; -import { shouldSearchAncestors } from "./workdir-search.ts"; +import { describeConfigLoadFailure, loadResolvedConfigContext } from "./resolved-config-context.ts"; +import { isConfigValueFailure } from "./config-value-passthrough.ts"; +import { loadCliProjectEnvFiles } from "../shared/config/cli-config-env.ts"; import { promptYesNo } from "./prompt-yes-no.ts"; import { resolveStorageCredentials, @@ -123,7 +123,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { */ readonly yes?: boolean; /** - * Skips this function's own `loadCliConfig` reload in favor of a config the caller already + * Skips this function's own resolved config load in favor of a config the caller already * resolved through its own nested-env walk, so a fresh reload here can't drop an override * that exists only in the shell/dotenv, not in `config.toml`. */ @@ -159,23 +159,21 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const posixPath = yield* Effect.provide(Path.Path, BunPath.layerPosix); - const projectEnvValues = opts.projectEnvValues ?? (yield* loadProjectEnv(fs, path, workdir)); - // `--yes` OR `SUPABASE_YES`. - const yes = opts.yes ?? (yield* resolveYesWithProjectEnv(projectEnvValues)); const { projectRef, emitSummary } = opts; const interactive = opts.interactive ?? true; const promptless = opts.promptless ?? false; - // Loads config.toml, merging `[remotes.*]` overrides for `--linked`; skipped when the - // caller already supplied `resolvedConfig`. - // An explicit `opts.workdir` is the exact project root; only the caller's own workdir - // (`cliSettings.workdir`) may still search ancestors for `config.toml`. - const search = opts.workdir === undefined && shouldSearchAncestors(cliSettings); - const loadOptions: CliConfigLoadOptions = projectRef !== "" ? { projectRef, search } : { search }; - const loaded = + // Skipped when the caller already supplied `resolvedConfig`. A missing config file behaves as + // the embedded defaults, not an early exit: local + no-config falls into the no-op + // short-circuit below, while `--linked` + no-config still falls through to the remote path so + // auth/project/API failures surface. + const context = opts.resolvedConfig !== undefined - ? null - : yield* loadCliConfig(workdir, loadOptions).pipe( + ? undefined + : yield* loadResolvedConfigContext( + workdir, + projectRef === "" ? Option.none() : Option.some(projectRef), + ).pipe( Effect.catchTag( "CliConfigParseError", (cause) => @@ -183,17 +181,30 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, }), ), + Effect.mapError((cause) => + cause instanceof SeedConfigLoadError || isConfigValueFailure(cause) + ? cause + : new SeedConfigLoadError({ message: describeConfigLoadFailure(cause) }), + ), ); - // A missing config file behaves as embedded defaults, not an early exit: local + no-config - // falls into the no-op short-circuit below, while `--linked` + no-config still falls - // through to the remote path so auth/project/API failures surface. - const config = - opts.resolvedConfig?.config ?? (loaded === null ? decodeDefaultCliConfig({}) : loaded.config); - const document = opts.resolvedConfig?.document ?? (loaded === null ? undefined : loaded.document); + const projectEnvValues = + opts.projectEnvValues ?? + context?.projectEnvValues ?? + (yield* loadCliProjectEnvFiles(workdir).pipe( + Effect.map((loaded) => loaded.values), + Effect.mapError((cause) => new SeedConfigLoadError({ message: cause.message })), + )); + // `--yes` OR `SUPABASE_YES`. + const yes = opts.yes ?? (yield* resolveYesWithProjectEnv(projectEnvValues)); + const config = opts.resolvedConfig?.config ?? context?.config ?? decodeDefaultCliConfig({}); + const document = opts.resolvedConfig?.document ?? context?.document; // Printed whenever a `[remotes.*]` block matched the linked ref; stderr in all output modes. - if (loaded !== null && loaded.appliedRemote !== undefined) { - yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + const appliedRemote = Option.getOrUndefined( + context?.resolvedConfig.appliedRemote ?? Option.none(), + ); + if (appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${appliedRemote}]\n`, "stderr"); } const bucketsConfig = config.storage.buckets ?? {}; const bucketNames = Object.keys(bucketsConfig); @@ -227,7 +238,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // consults these legacy-only inputs, so it skips this validation entirely. const backend = yield* currentStackBackend; if (backend.kind !== "stack") { - yield* validateLocalStorageConfig(config, projectEnvValues); + yield* validateLocalStorageConfig(); } if (emitSummary && output.format !== "text") { yield* output.success("", { ...emptySummary() }); @@ -236,13 +247,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { } // Build the Storage service-gateway client (local or remote). - const credentials = - opts.credentials ?? - (yield* resolveStorageCredentials({ - projectRef, - config, - projectEnvValues, - })); + const credentials = opts.credentials ?? (yield* resolveStorageCredentials({ projectRef })); // Gateway operations use an explicit non-DoH fetch (CA-trusting for local + https, plain // `globalThis.fetch` otherwise); the api-keys lookup in `resolveStorageCredentials` runs diff --git a/apps/cli/src/command-internal/seed-remote-consent.errors.ts b/apps/cli/src/command-internal/seed-remote-consent.errors.ts new file mode 100644 index 0000000000..424b028b7c --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.errors.ts @@ -0,0 +1,16 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; + +/** Seeding a project that matched a `[remotes.*]` block needs consent, and this run can't prompt for it. */ +export class SeedConsentRequiredError extends Data.TaggedError("SeedConsentRequiredError")<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts new file mode 100644 index 0000000000..5b4582b456 --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -0,0 +1,125 @@ +import { Effect, Option, Path, Predicate } from "effect"; + +import { CliConfigKeys } from "../config/cli-config-keys.ts"; +import { describeCliConfigOrigin } from "../config/cli-config-key.ts"; +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { Tty } from "../shared/runtime/tty.service.ts"; +import { promptYesNoOutcome } from "./prompt-yes-no.ts"; +import { SeedConsentRequiredError } from "./seed-remote-consent.errors.ts"; + +/** A seed run that needs consent because its target matched a `[remotes.*]` block. */ +export interface SeedConsentTarget { + readonly ref: string; + readonly remote: string; + /** What turned seeding on, as `describeCliConfigOrigin` words it. */ + readonly enabledBy: string; +} + +/** The effective `[db.seed]` values a command acts on, plus the consent its target needs. */ +export interface DbSeedInput { + readonly enabled: boolean; + readonly sqlPaths: ReadonlyArray; + readonly consent: SeedConsentTarget | undefined; +} + +type SeedConsentCommand = "push" | "reset"; + +const SEED_CONSENT_SUGGESTIONS: Record = { + push: "Pass --yes to seed, or drop --include-seed to push migrations only.", + reset: "Pass --yes to seed, or --no-seed to reset without seeding.", +}; + +const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: string): boolean => { + const block = resolvedConfig.loaded.interpolatedRemotes?.[remote]; + const declared = + Predicate.hasProperty(block, "db") && + Predicate.hasProperty(block.db, "seed") && + Predicate.hasProperty(block.db.seed, "enabled") + ? block.db.seed.enabled + : undefined; + return CliConfigKeys.db.seed.enabled.codec.fromConfig(declared) === true; +}; + +/** + * Reads `[db.seed]` through the resolved config. Seeding needs consent only when the target matched a + * `[remotes.*]` block that does not itself turn seeding on. + */ +export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( + resolvedConfig: ResolvedCliConfig, + target: { readonly workdir: string; readonly ref: string }, +) { + const path = yield* Path.Path; + const enabled = yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled); + const sqlPaths = yield* resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths); + const remote = Option.getOrUndefined(resolvedConfig.appliedRemote); + const consent: SeedConsentTarget | undefined = + remote === undefined || remoteDeclaresSeedEnabled(resolvedConfig, remote) + ? undefined + : { + ref: target.ref, + remote, + enabledBy: describeCliConfigOrigin(enabled.origin, { workdir: target.workdir, path }), + }; + return { enabled: enabled.value, sqlPaths: sqlPaths.value, consent } satisfies DbSeedInput; +}); + +/** Whether a prompt can reach someone: a real terminal, or a piped line standing in for one. */ +const canPromptForSeed = Effect.fnUntraced(function* () { + const output = yield* Output; + const tty = yield* Tty; + return tty.stdinIsTty ? output.interactive && output.format === "text" : true; +}); + +const requiredError = (target: SeedConsentTarget, command: SeedConsentCommand) => + new SeedConsentRequiredError({ + message: `Seeding ${target.ref} ([remotes.${target.remote}]) needs confirmation and this run can't prompt. Nothing was changed.`, + suggestion: SEED_CONSENT_SUGGESTIONS[command], + }); + +/** The note `--dry-run` prints so a real run's consent step isn't a surprise. */ +export const seedConsentDryRunNote = Effect.fnUntraced(function* ( + target: SeedConsentTarget, + yes: boolean, +) { + if (yes) return; + const output = yield* Output; + const action = (yield* canPromptForSeed()) + ? "will ask before seeding" + : "will need --yes to seed"; + yield* output.raw(`A real run ${action} ${target.ref} ([remotes.${target.remote}]).\n`, "stderr"); +}); + +/** + * Asks before seeding a project whose target matched a `[remotes.*]` block, defaulting to no. + * Returns `false` when the answer is no; fails with `SeedConsentRequiredError` when nothing can ask + * or piped stdin ends without an answer line. + */ +export const confirmSeedIntoMatchedRemote = Effect.fnUntraced(function* (input: { + readonly command: SeedConsentCommand; + readonly target: SeedConsentTarget; + readonly files: ReadonlyArray; + readonly yes: boolean; +}) { + const { command, target, files, yes } = input; + const output = yield* Output; + if (!yes && !(yield* canPromptForSeed())) return yield* requiredError(target, command); + const noun = files.length === 1 ? "seed file" : "seed files"; + const outcome = yield* promptYesNoOutcome( + output, + yes, + `Project ${target.ref} matches [remotes.${target.remote}]. Run ${files.length} ${noun} (${files.join(", ")}) against it?`, + false, + true, + { readMachineStdin: true }, + ); + if (!outcome.answered) return yield* requiredError(target, command); + if (outcome.value && yes) yield* output.raw(`Seeding enabled by ${target.enabledBy}\n`, "stderr"); + return outcome.value; +}); + +/** The cancelled message both commands use when the seed prompt is answered no. */ +export const SEED_CANCELLED_MESSAGE = "Seeding cancelled; nothing was changed."; + +export const seedCancelledSuggestion = (command: SeedConsentCommand): string => + SEED_CONSENT_SUGGESTIONS[command]; diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index c3c15d3cd9..28434f7b69 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import type { DbSession } from "./db-connection.service.ts"; import { applySeedFiles } from "./seed.ts"; @@ -36,7 +37,7 @@ const run = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* applySeedFiles(session, fs, path, workdir, { enabled: true, sqlPaths }); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cliConfigProviderLayer))); describe("applySeedFiles seed glob", () => { it.effect("treats a backslash escape as a glob metacharacter (matches the real file)", () => { diff --git a/apps/cli/src/command-internal/service-version-overrides.ts b/apps/cli/src/command-internal/service-version-overrides.ts index f2a8c3d656..2bbd116884 100644 --- a/apps/cli/src/command-internal/service-version-overrides.ts +++ b/apps/cli/src/command-internal/service-version-overrides.ts @@ -8,7 +8,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import { tempPaths } from "./temp-paths.ts"; +import { tempPaths } from "../shared/config/temp-paths.ts"; /** A saved `.temp` pin is not a usable image tag. The file is left unchanged. */ export class InvalidServiceVersionTagError extends Data.TaggedError( diff --git a/apps/cli/src/command-internal/smtp-enabled.integration.test.ts b/apps/cli/src/command-internal/smtp-enabled.integration.test.ts new file mode 100644 index 0000000000..de2d1710dc --- /dev/null +++ b/apps/cli/src/command-internal/smtp-enabled.integration.test.ts @@ -0,0 +1,50 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { definedEnv } from "../../tests/helpers/config-env-pins.ts"; +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { createStackConfigProject } from "../../tests/helpers/stack-config.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; + +const smtpEnabled = (config: string, shell: Record = {}) => + Effect.gen(function* () { + const root = yield* createStackConfigProject(config, { prefix: "supabase-smtp-enabled-" }); + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(configValuesLayer())); + return resolveSmtpEnabled(resolvedConfig); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withConfigEnv(definedEnv(shell), effect), + Effect.scoped, + ); + +describe("resolveSmtpEnabled", () => { + it.live("is off when [auth.email.smtp] is absent", () => + Effect.gen(function* () { + expect(yield* smtpEnabled('project_id = "p"\n')).toBe(false); + }), + ); + + it.live("is on when the table is present and leaves enabled out", () => + Effect.gen(function* () { + expect(yield* smtpEnabled('[auth.email.smtp]\nhost = "smtp.test"\n')).toBe(true); + }), + ); + + it.live("follows an explicit enabled = false", () => + Effect.gen(function* () { + expect(yield* smtpEnabled("[auth.email.smtp]\nenabled = false\n")).toBe(false); + }), + ); + + it.live("follows an env override of enabled over a present table", () => + Effect.gen(function* () { + const config = "[auth.email.smtp]\nenabled = true\n"; + expect(yield* smtpEnabled(config, { SUPABASE_AUTH_EMAIL_SMTP_ENABLED: "false" })).toBe(false); + }), + ); +}); diff --git a/apps/cli/src/command-internal/smtp-enabled.ts b/apps/cli/src/command-internal/smtp-enabled.ts new file mode 100644 index 0000000000..4ab93b7f0b --- /dev/null +++ b/apps/cli/src/command-internal/smtp-enabled.ts @@ -0,0 +1,7 @@ +import type { ResolvedCliConfig } from "../config/cli-config-values.service.ts"; + +/** A present `[auth.email.smtp]` table without `enabled` counts as on; the schema default is off. */ +export const resolveSmtpEnabled = (resolvedConfig: ResolvedCliConfig): boolean => + resolvedConfig.materialized.originAt("auth.email.smtp.enabled").tier === "default" + ? resolvedConfig.declares("auth.email.smtp") + : resolvedConfig.materialized.config.auth.email.smtp?.enabled === true; diff --git a/apps/cli/src/command-internal/stack-auth-config.ts b/apps/cli/src/command-internal/stack-auth-config.ts index d5f818e5d9..c6529a6b70 100644 --- a/apps/cli/src/command-internal/stack-auth-config.ts +++ b/apps/cli/src/command-internal/stack-auth-config.ts @@ -28,6 +28,7 @@ const passThrough = section; interface ResolvedAuthOptions { + readonly smtpEnabled: boolean; readonly authExternalUrl?: string; readonly apiExternalUrl?: string; readonly passkeyEnabled?: boolean; @@ -115,7 +116,7 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( web3: passThrough()(auth.web3), oauthServer: passThrough()(auth.oauth_server), }, - ...(auth.email.smtp?.enabled !== true + ...(options.smtpEnabled !== true || auth.email.smtp === undefined ? localSmtp.enabled ? { smtpAdminEmail: localSmtp.admin_email ?? "admin@email.com", diff --git a/apps/cli/src/command-internal/stack-backend.ts b/apps/cli/src/command-internal/stack-backend.ts index 80438eb9d6..cdf0796635 100644 --- a/apps/cli/src/command-internal/stack-backend.ts +++ b/apps/cli/src/command-internal/stack-backend.ts @@ -63,7 +63,6 @@ export const currentStackBackend: Effect.Effect<{ readonly kind: StackBackend }, export const resolveStackBackend = (input: { readonly args: ReadonlyArray; readonly cwd: string; - readonly env: Readonly>; }): Effect.Effect => Effect.gen(function* () { // Completion passes the final token as the cursor word, so it is not part of @@ -92,7 +91,6 @@ export const resolveStackBackend = (input: { const enabled = yield* resolveExperimentalFeature({ feature: "stack", configValue: readExperimentalFeatureConfig({ feature: "stack", ...input, args: routingArgs }), - env: input.env, }).pipe( Effect.mapError((error) => new StackRoutingError({ message: error.message, cause: error })), ); diff --git a/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts b/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts new file mode 100644 index 0000000000..e423998aae --- /dev/null +++ b/apps/cli/src/command-internal/stack-config.precedence.integration.test.ts @@ -0,0 +1,193 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect"; + +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { ConfigEnvPins } from "../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../tests/helpers/config-goldens.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { loadStackConfig } from "./stack-config.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const STACK_ID = "golden-stack"; +const GOLDEN_DIR = "./testdata/config-precedence/stack-config"; + +const BASE_CONFIG = `project_id = "golden-stack" + +[api] +port = 55321 +schemas = ["public", "base_api"] + +[db] +port = 55322 + +[auth] +site_url = "http://base.example.com" +enable_signup = true + +[studio] +port = 55323 + +[realtime] +enabled = true + +[storage] +enabled = true +`; + +const REMOTE_BLOCK = ` +[remotes.prod] +project_id = "${TARGET_REF}" + +[remotes.prod.api] +schemas = ["public", "remote_api"] + +[remotes.prod.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.prod.realtime] +enabled = false +`; + +const SHELL_ENV = { + SUPABASE_API_PORT: "56321", + SUPABASE_DB_PORT: "56322", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_REALTIME_ENABLED: "false", + SUPABASE_STUDIO_ENABLED: "false", +}; + +const DOTENV = [ + "SUPABASE_API_PORT=57321", + "SUPABASE_DB_PORT=57322", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_AUTH_SITE_URL=http://dotenv.example.com", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_REALTIME_ENABLED=false", + "SUPABASE_STUDIO_ENABLED=false", + "", +].join("\n"); + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; + readonly contextRef?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "no-overrides", + name: "stack config with base config and no env resolves the base values (pre-refactor)", + config: BASE_CONFIG, + }, + { + golden: "remote-block-ignored", + name: "stack config ignores a [remotes.*] block because no caller passes a ref (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + }, + { + golden: "remote-block-merged-via-context", + name: "stack config applies a remote block only when the caller preloads a ref-scoped context (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + contextRef: TARGET_REF, + }, + { + golden: "remote-block-merged-via-context-with-shell-env", + name: "stack config lets shell env beat a merged remote block for the same keys (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: SHELL_ENV, + contextRef: TARGET_REF, + }, + { + golden: "shell-env", + name: "stack config shell env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + shellEnv: SHELL_ENV, + }, + { + golden: "project-dotenv", + name: "stack config supabase/.env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + dotenv: DOTENV, + }, + { + golden: "shell-beats-dotenv", + name: "stack config shell env beats supabase/.env for the same keys (pre-refactor)", + config: BASE_CONFIG, + shellEnv: { + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + }, + dotenv: DOTENV, + }, +]; + +const configEnvOnly = (env: Readonly>) => + Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SUPABASE_"))); + +const tempRoot = useTempWorkdir("stack-config-golden-"); + +describe("stack config precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.live(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(fixture.shellEnv ?? {}); + + const layer = Layer.mergeAll( + BunServices.layer, + runtimeInfoLayer, + cliConfigValuesTestLayer, + Layer.succeed(ConfigEnvPins, fixture.shellEnv ?? {}), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => + value === undefined ? [] : [[name, value]], + ), + ), + { preserveEmptyStrings: true }, + ), + ), + ); + + const config = yield* loadStackConfig( + workdir, + fixture.contextRef === undefined ? undefined : { projectRef: fixture.contextRef }, + ).pipe(Effect.provide(layer)); + + const creations = yield* config.creations(STACK_ID); + const keys = yield* config.keys; + const golden = { + creations, + keys, + source: config.source, + projectEnvValues: configEnvOnly(config.projectEnvValues), + }; + yield* Effect.promise(() => + ctx + .expect(goldenJson(golden, { [workdir]: "" })) + .toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 84481fd686..8be0f888e9 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -1,6 +1,4 @@ import { getDefaultCliConfig, type CliConfig } from "@supabase/config"; -import { resolveCliConfigSubtree } from "./cli-config-load.ts"; -import { validateCliConfig } from "@supabase/config/effect"; import { DEFAULT_LOCAL_S3_ACCESS_KEY_ID, DEFAULT_LOCAL_S3_REGION, @@ -8,51 +6,33 @@ import { DEFAULT_SIGNING_KEY, } from "@supabase/stack/defaults"; import { type ServiceCreationInput as ServiceCreationType } from "@supabase/stack/effect"; -import { Crypto, Effect, Data, FileSystem, Path, Redacted, Schema, SchemaIssue } from "effect"; +import { Crypto, Effect, Data, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; -import { loadLocalProjectContext, type LocalProjectContext } from "./local-project-context.ts"; +import { CliConfigKeys, type AnyCliConfigKey } from "../config/cli-config-keys.ts"; +import { resolveConfigSubtree } from "../config/cli-config-subtree.ts"; +import type { + CliConfigMaterialized, + CliConfigValues, +} from "../config/cli-config-values.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; +import { decryptSecretMap } from "../shared/config/vault-decrypt.ts"; +import { + describeConfigLoadFailure, + loadResolvedConfigContext, + resolvePasskeyWebauthn, +} from "./resolved-config-context.ts"; import { resolveAuthConfig } from "./stack-auth-config.ts"; +import { resolveSmtpEnabled } from "./smtp-enabled.ts"; import { parseDuration } from "./duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; import { stackDatabaseVersion } from "./stack-database-version.ts"; import { - decryptAuthSecret, resolveJwtSecret, resolveConfiguredSigningKeys, - envOverride, - envOverrideApiMaxRows, - envOverrideAuthPasswordRequirements, - envOverrideAnalyticsBackend, - envOverrideBool, - envOverrideDefaultPoolSize, - envOverrideDenoVersion, - envOverrideEdgeRuntimePolicy, - envOverrideMaxClientConn, - envOverrideMajorVersion, - envOverridePort, - envOverridePoolMode, - envOverrideRealtimeIpVersion, - envOverrideRealtimeMaxHeaderLength, - envOverrideUint, - resolveAuthCaptcha, - resolveAuthEmail, - resolveAuthEmailSmtp, - resolveAuthExternalUrl, resolveAuthExternalProviders, - resolveAuthHooks, - resolveAuthMfa, - resolveAuthSms, - resolveDbSettingsEnvOverrides, - resolveGotrueOAuthServer, - resolveGotruePasskeyWebauthn, - resolveGotrueRateLimit, - resolveGotrueSessions, - resolveGotrueWeb3, - strToArr, } from "./local-config-values.ts"; import { generateAsymmetricLocalJwt } from "./local-jwt.ts"; import { recordOrioleDbTelemetry } from "./db-image.ts"; @@ -83,7 +63,7 @@ interface StackStartConfig { ) => Effect.Effect, StackConfigError>; readonly source: CliConfig; readonly projectEnvValues: Readonly>; - readonly document?: Record; + readonly originAt: CliConfigMaterialized["originAt"]; readonly remoteJwks: Effect.Effect; readonly keys: Effect.Effect< { @@ -104,7 +84,7 @@ interface StackStartConfig { type StackConfigEffect = Effect.Effect< StackStartConfig, StackConfigError, - FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto + FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto | CliConfigValues >; const isRecord = (value: unknown): value is Readonly> => @@ -112,691 +92,39 @@ const isRecord = (value: unknown): value is Readonly> => const encodeJwkArray = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Unknown))); -const withoutUndefined = (value: unknown): unknown => { - if (Redacted.isRedacted(value)) return value; - if (Array.isArray(value)) return value.map(withoutUndefined); - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value) - .filter(([, item]) => item !== undefined) - .map(([key, item]) => [key, withoutUndefined(item)]), - ); -}; - const section = (document: Readonly> | undefined, name: string) => { const value = document?.[name]; return isRecord(value) ? value : undefined; }; -/** A setting's `config.toml` key and the `SUPABASE_*` env var that overrides it. */ -export interface StackEndpointSetting { - readonly configPath: string; - readonly envVar: string; -} - -// Shared by `createCreations` and `stackEndpointSetting`; a new endpoint also needs an entry in -// `endpointSettingsByServiceEndpoint`. -const DB_PORT: StackEndpointSetting = { - envVar: "SUPABASE_DB_PORT", - configPath: "db.port", -}; -const API_PORT: StackEndpointSetting = { - envVar: "SUPABASE_API_PORT", - configPath: "api.port", -}; -const STUDIO_PORT: StackEndpointSetting = { - envVar: "SUPABASE_STUDIO_PORT", - configPath: "studio.port", -}; -const DB_POOLER_PORT: StackEndpointSetting = { - envVar: "SUPABASE_DB_POOLER_PORT", - configPath: "db.pooler.port", -}; -const LOCAL_SMTP_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_PORT", - configPath: "local_smtp.port", -}; -const LOCAL_SMTP_SMTP_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT", - configPath: "local_smtp.smtp_port", -}; -const LOCAL_SMTP_POP3_PORT: StackEndpointSetting = { - envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT", - configPath: "local_smtp.pop3_port", -}; -const ANALYTICS_PORT: StackEndpointSetting = { - envVar: "SUPABASE_ANALYTICS_PORT", - configPath: "analytics.port", -}; -const EDGE_RUNTIME_INSPECTOR_PORT: StackEndpointSetting = { - envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - configPath: "edge_runtime.inspector_port", -}; - -/** The configured port when the setting is present in `config.toml` or its env var, else undefined. */ -const resolvePort = ( - setting: StackEndpointSetting, - document: Readonly> | undefined, - configured: number, - env: Readonly>, -): number | undefined => { - if (envOverride(setting.envVar, undefined, env) !== undefined) return configured; - const path = setting.configPath.split("."); - const key = path[path.length - 1] ?? ""; - const parent = path - .slice(0, -1) - .reduce> | undefined>( - (node, name) => section(node, name), - document, - ); - return typeof parent?.[key] === "number" ? configured : undefined; -}; +/** The config key behind a saved stack endpoint; its `path` and `env[0]` name the setting. */ +export type StackEndpointKey = Pick; /** - * Maps a saved stack endpoint (service + endpoint name) to the `StackEndpointSetting` that controls it. + * Maps a saved stack endpoint (service + endpoint name) to the config key that controls it. * An endpoint missing here (e.g. `pooler.http`, `realtime.rpc`) is always automatic. */ -const endpointSettingsByServiceEndpoint: Readonly> = { - "database.sql": DB_PORT, - "pooler.sql": DB_POOLER_PORT, - "analytics.http": ANALYTICS_PORT, - "studio.http": STUDIO_PORT, - "mail.http": LOCAL_SMTP_PORT, - "mail.smtp": LOCAL_SMTP_SMTP_PORT, - "mail.pop3": LOCAL_SMTP_POP3_PORT, - "functions.inspector": EDGE_RUNTIME_INSPECTOR_PORT, - "rest.http": API_PORT, - "auth.http": API_PORT, - "realtime.http": API_PORT, - "storage.http": API_PORT, - "functions.http": API_PORT, -}; - -/** The config.toml key and env var override for a service endpoint, when the CLI exposes one. */ -export const stackEndpointSetting = ( - service: string, - endpoint: string, -): StackEndpointSetting | undefined => endpointSettingsByServiceEndpoint[`${service}.${endpoint}`]; - -/** - * `db.major_version`'s config key and `SUPABASE_DB_MAJOR_VERSION` override. Unlike the ports - * above, `envOverrideMajorVersion` (shared with `db-bootstrap` and the legacy local stack) hardcodes - * its own name/field, so there is no single call site to read this from without widening that - * shared helper's signature; the two literals here are kept in sync by hand. - */ -export const stackMajorVersionSetting: StackEndpointSetting = { - configPath: "db.major_version", - envVar: "SUPABASE_DB_MAJOR_VERSION", -}; - -/** `db.orioledb_version`'s config key and `SUPABASE_DB_ORIOLEDB_VERSION` override. */ -export const stackOrioledbVersionSetting: StackEndpointSetting = { - configPath: "db.orioledb_version", - envVar: "SUPABASE_DB_ORIOLEDB_VERSION", +const endpointKeysByServiceEndpoint: Readonly> = { + "database.sql": CliConfigKeys.db.port, + "pooler.sql": CliConfigKeys.db.pooler.port, + "analytics.http": CliConfigKeys.analytics.port, + "studio.http": CliConfigKeys.studio.port, + "mail.http": CliConfigKeys.localSmtp.port, + "mail.smtp": CliConfigKeys.localSmtp.smtpPort, + "mail.pop3": CliConfigKeys.localSmtp.pop3Port, + "functions.inspector": CliConfigKeys.edgeRuntime.inspectorPort, + "rest.http": CliConfigKeys.api.port, + "auth.http": CliConfigKeys.api.port, + "realtime.http": CliConfigKeys.api.port, + "storage.http": CliConfigKeys.api.port, + "functions.http": CliConfigKeys.api.port, }; -const authProviderNames = [ - "apple", - "azure", - "bitbucket", - "discord", - "facebook", - "github", - "gitlab", - "google", - "kakao", - "keycloak", - "linkedin_oidc", - "notion", - "twitch", - "twitter", - "x", - "slack_oidc", - "spotify", - "workos", - "zoom", -] as const; - -const resolvedPort = ( - name: string, - configured: number, - field: string, - env: Readonly>, -) => - envOverride(name, undefined, env) === undefined - ? configured - : envOverridePort(name, configured, field, env); - -const resolveAuthOverrides = ( - auth: CliConfig["auth"], - document: Readonly> | undefined, - env: Readonly>, -) => { - const authDocument = section(document, "auth"); - const email = resolveAuthEmail(auth.email, authDocument, env); - const smtp = resolveAuthEmailSmtp(authDocument, env); - const hooks = resolveAuthHooks(authDocument, auth.hook, env); - const external = resolveAuthExternalProviders(authDocument, auth.external, env); - const externalResolved = { ...auth.external }; - for (const name of authProviderNames) { - const value = external[name]; - if (value === undefined) continue; - externalResolved[name] = { - ...externalResolved[name], - enabled: value.enabled, - client_id: value.clientId ?? externalResolved[name].client_id, - secret: value.secret, - url: value.url, - redirect_uri: value.redirectUri ?? externalResolved[name].redirect_uri, - skip_nonce_check: value.skipNonceCheck, - email_optional: value.emailOptional, - }; - } - const figma = external.figma; - if (figma !== undefined) { - externalResolved.figma = { - ...externalResolved.figma, - enabled: figma.enabled, - client_id: figma.clientId ?? externalResolved.figma?.client_id, - secret: figma.secret, - url: figma.url, - redirect_uri: figma.redirectUri ?? externalResolved.figma?.redirect_uri, - skip_nonce_check: figma.skipNonceCheck, - email_optional: figma.emailOptional, - }; - } - const hookValue = ( - value: (typeof hooks)[keyof typeof hooks], - fallback: (typeof auth.hook)[keyof typeof auth.hook], - ) => value ?? fallback; - const hookResolved = { - mfa_verification_attempt: hookValue( - hooks.mfaVerificationAttempt, - auth.hook.mfa_verification_attempt, - ), - password_verification_attempt: hookValue( - hooks.passwordVerificationAttempt, - auth.hook.password_verification_attempt, - ), - custom_access_token: hookValue(hooks.customAccessToken, auth.hook.custom_access_token), - send_sms: hookValue(hooks.sendSms, auth.hook.send_sms), - send_email: hookValue(hooks.sendEmail, auth.hook.send_email), - before_user_created: hookValue(hooks.beforeUserCreated, auth.hook.before_user_created), - }; - const resolvedEmailTemplate: CliConfig["auth"]["email"]["template"] = Object.fromEntries( - Object.entries(email.template).map(([name, value]) => [ - name, - { subject: value.subject ?? "", content_path: value.content_path }, - ]), - ); - const resolvedEmailNotification: CliConfig["auth"]["email"]["notification"] = Object.fromEntries( - Object.entries(email.notification).map(([name, value]) => [ - name, - { - enabled: value.enabled, - subject: value.subject ?? "", - content_path: value.content_path, - }, - ]), - ); - const resolvedEmail = { - ...email, - template: resolvedEmailTemplate, - notification: resolvedEmailNotification, - }; - const additionalRedirectUrls = envOverride( - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - undefined, - env, - ); - const resolvedSmtp = - smtp === undefined - ? undefined - : { - enabled: smtp.enabled, - host: smtp.host, - ...(smtp.port === 0 ? {} : { port: smtp.port }), - user: smtp.user, - pass: smtp.pass, - admin_email: smtp.adminEmail, - sender_name: smtp.senderName, - }; - const resolvedSms = resolveAuthSms(authDocument, auth.sms, env); - return { - ...auth, - enabled: envOverrideBool("SUPABASE_AUTH_ENABLED", auth.enabled, "auth.enabled", env), - site_url: envOverride("SUPABASE_AUTH_SITE_URL", auth.site_url, env), - additional_redirect_urls: - additionalRedirectUrls === undefined - ? auth.additional_redirect_urls - : strToArr(additionalRedirectUrls), - jwt_expiry: envOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - auth.jwt_expiry, - env, - ), - enable_refresh_token_rotation: envOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - env, - ), - refresh_token_reuse_interval: envOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - auth.refresh_token_reuse_interval, - env, - ), - enable_manual_linking: envOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - auth.enable_manual_linking, - "auth.enable_manual_linking", - env, - ), - enable_signup: envOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - auth.enable_signup, - "auth.enable_signup", - env, - ), - enable_anonymous_sign_ins: envOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - env, - ), - minimum_password_length: envOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - auth.minimum_password_length, - env, - ), - password_requirements: envOverrideAuthPasswordRequirements(auth.password_requirements, env), - publishable_key: envOverride("SUPABASE_AUTH_PUBLISHABLE_KEY", auth.publishable_key, env), - secret_key: envOverride("SUPABASE_AUTH_SECRET_KEY", auth.secret_key, env), - anon_key: envOverride("SUPABASE_AUTH_ANON_KEY", auth.anon_key, env), - service_role_key: envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, env), - rate_limit: resolveGotrueRateLimit(auth.rate_limit, env), - captcha: resolveAuthCaptcha(authDocument, auth.captcha, env), - hook: hookResolved, - mfa: resolveAuthMfa(auth.mfa, env), - sessions: resolveGotrueSessions(auth.sessions, env), - email: { ...resolvedEmail, smtp: resolvedSmtp }, - sms: { - ...resolvedSms, - twilio: { - ...resolvedSms.twilio, - content_sid: envOverride( - "SUPABASE_AUTH_SMS_TWILIO_CONTENT_SID", - auth.sms.twilio.content_sid, - env, - ), - }, - }, - external: externalResolved, - web3: resolveGotrueWeb3(auth.web3, env), - oauth_server: resolveGotrueOAuthServer(auth.oauth_server, env), - }; -}; +/** The config key that sets a service endpoint's port, when the CLI exposes one. */ +export const stackEndpointKey = (service: string, endpoint: string): StackEndpointKey | undefined => + endpointKeysByServiceEndpoint[`${service}.${endpoint}`]; -const resolveEffectiveCliConfig = ( - config: CliConfig, - document: Readonly> | undefined, - env: Readonly>, -): CliConfig => { - const api = config.api; - const db = config.db; - const storage = config.storage; - const realtime = config.realtime; - const analytics = config.analytics; - const studio = config.studio; - const mail = config.local_smtp; - const pooler = db.pooler; - const edge = config.edge_runtime; - const experimental = config.experimental; - const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, env); - const apiExtraSearchPathOverride = envOverride("SUPABASE_API_EXTRA_SEARCH_PATH", undefined, env); - const imageDocument = section(section(document, "storage"), "image_transformation"); - const imageEnabledOverride = envOverride( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - undefined, - env, - ); - const imageEnabledExplicit = - imageDocument?.enabled !== undefined || imageEnabledOverride !== undefined; - const resolvedApi = { - ...api, - enabled: envOverrideBool("SUPABASE_API_ENABLED", api.enabled, "api.enabled", env), - port: resolvedPort("SUPABASE_API_PORT", api.port, "api.port", env), - schemas: apiSchemasOverride === undefined ? api.schemas : strToArr(apiSchemasOverride), - extra_search_path: - apiExtraSearchPathOverride === undefined - ? api.extra_search_path - : strToArr(apiExtraSearchPathOverride), - max_rows: envOverrideApiMaxRows(api.max_rows, env), - tls: { - ...api.tls, - enabled: envOverrideBool("SUPABASE_API_TLS_ENABLED", api.tls.enabled, "api.tls.enabled", env), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, env), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, env), - }, - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, env), - auto_expose_new_tables: - api.auto_expose_new_tables !== undefined || - envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", undefined, env) !== undefined - ? envOverrideBool( - "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", - api.auto_expose_new_tables ?? false, - "api.auto_expose_new_tables", - env, - ) - : undefined, - }; - const resolvedStorage = { - ...storage, - enabled: envOverrideBool("SUPABASE_STORAGE_ENABLED", storage.enabled, "storage.enabled", env), - file_size_limit: envOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - String(storage.file_size_limit), - env, - ), - image_transformation: imageEnabledExplicit - ? { - ...storage.image_transformation, - enabled: envOverrideBool( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - storage.image_transformation?.enabled ?? false, - "storage.image_transformation.enabled", - env, - ), - } - : undefined, - s3_protocol: { - ...storage.s3_protocol, - enabled: envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - env, - ), - }, - analytics: { - ...storage.analytics, - enabled: envOverrideBool( - "SUPABASE_STORAGE_ANALYTICS_ENABLED", - storage.analytics.enabled, - "storage.analytics.enabled", - env, - ), - max_namespaces: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", - "storage.analytics.max_namespaces", - storage.analytics.max_namespaces, - env, - ), - max_tables: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", - "storage.analytics.max_tables", - storage.analytics.max_tables, - env, - ), - max_catalogs: envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", - "storage.analytics.max_catalogs", - storage.analytics.max_catalogs, - env, - ), - }, - vector: { - ...storage.vector, - enabled: envOverrideBool( - "SUPABASE_STORAGE_VECTOR_ENABLED", - storage.vector.enabled, - "storage.vector.enabled", - env, - ), - max_buckets: envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", - "storage.vector.max_buckets", - storage.vector.max_buckets, - env, - ), - max_indexes: envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", - "storage.vector.max_indexes", - storage.vector.max_indexes, - env, - ), - }, - }; - const resolvedEdge = { - ...edge, - enabled: envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - edge.enabled, - "edge_runtime.enabled", - env, - ), - policy: envOverrideEdgeRuntimePolicy(edge.policy, env), - deno_version: envOverrideDenoVersion(edge.deno_version, env), - inspector_port: resolvedPort( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - edge.inspector_port, - "edge_runtime.inspector_port", - env, - ), - }; - const resolvedAnalytics = { - ...analytics, - enabled: envOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - analytics.enabled, - "analytics.enabled", - env, - ), - port: resolvedPort("SUPABASE_ANALYTICS_PORT", analytics.port, "analytics.port", env), - backend: envOverrideAnalyticsBackend(analytics.backend, env), - gcp_project_id: envOverride("SUPABASE_ANALYTICS_GCP_PROJECT_ID", analytics.gcp_project_id, env), - gcp_project_number: envOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - analytics.gcp_project_number, - env, - ), - gcp_jwt_path: envOverride("SUPABASE_ANALYTICS_GCP_JWT_PATH", analytics.gcp_jwt_path, env), - }; - const resolvedPooler = { - ...pooler, - enabled: envOverrideBool( - "SUPABASE_DB_POOLER_ENABLED", - pooler.enabled, - "db.pooler.enabled", - env, - ), - port: resolvedPort("SUPABASE_DB_POOLER_PORT", pooler.port, "db.pooler.port", env), - pool_mode: envOverridePoolMode(pooler.pool_mode, env), - default_pool_size: envOverrideDefaultPoolSize(pooler.default_pool_size, env), - max_client_conn: envOverrideMaxClientConn(pooler.max_client_conn, env), - }; - const authEnabled = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - env, - ); - const thirdParty = { - firebase: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - env, - ), - project_id: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - env, - ), - }, - auth0: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - env, - ), - tenant: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - env, - ), - tenant_region: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - env, - ), - }, - aws_cognito: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - env, - ), - user_pool_id: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - env, - ), - user_pool_region: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - env, - ), - }, - clerk: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - env, - ), - domain: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - env, - ), - }, - workos: { - enabled: envOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - env, - ), - issuer_url: envOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - env, - ), - }, - }; - // JWT security settings apply to non-Auth workloads too, so resolve them - // regardless of whether the Auth capability is enabled. - const authResolved = { - ...(authEnabled ? resolveAuthOverrides(config.auth, document, env) : config.auth), - enabled: authEnabled, - third_party: thirdParty, - jwt_issuer: envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, env), - signing_keys_path: envOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - env, - ), - jwt_secret: decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, env), - env, - ), - }; - return { - ...config, - api: resolvedApi, - auth: authResolved, - db: { - ...db, - port: resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), - major_version: envOverrideMajorVersion(db.major_version, env), - health_timeout: envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), - orioledb_version: envOverride("SUPABASE_DB_ORIOLEDB_VERSION", db.orioledb_version, env), - settings: resolveDbSettingsEnvOverrides(db.settings, env), - pooler: resolvedPooler, - }, - edge_runtime: resolvedEdge, - // Same S3 overrides legacy Compose applies, so the OrioleDB S3 guard sees env-only values. - experimental: { - ...experimental, - s3_host: envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", experimental.s3_host, env), - s3_region: envOverride("SUPABASE_EXPERIMENTAL_S3_REGION", experimental.s3_region, env), - s3_access_key: envOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - experimental.s3_access_key, - env, - ), - s3_secret_key: envOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - experimental.s3_secret_key, - env, - ), - }, - realtime: { - ...realtime, - enabled: envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - realtime.enabled, - "realtime.enabled", - env, - ), - ip_version: envOverrideRealtimeIpVersion(realtime.ip_version, env), - max_header_length: envOverrideRealtimeMaxHeaderLength(realtime.max_header_length, env), - }, - storage: resolvedStorage, - analytics: resolvedAnalytics, - studio: { - ...studio, - enabled: envOverrideBool("SUPABASE_STUDIO_ENABLED", studio.enabled, "studio.enabled", env), - port: resolvedPort("SUPABASE_STUDIO_PORT", studio.port, "studio.port", env), - api_url: envOverride("SUPABASE_STUDIO_API_URL", studio.api_url, env), - openai_api_key: decryptAuthSecret( - envOverride("SUPABASE_STUDIO_OPENAI_API_KEY", studio.openai_api_key, env), - env, - ), - }, - local_smtp: { - ...mail, - enabled: envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - mail.enabled, - "local_smtp.enabled", - env, - ), - port: resolvedPort("SUPABASE_LOCAL_SMTP_PORT", mail.port, "local_smtp.port", env), - smtp_port: resolvedPort( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", - mail.smtp_port ?? 0, - "local_smtp.smtp_port", - env, - ), - pop3_port: resolvedPort( - "SUPABASE_LOCAL_SMTP_POP3_PORT", - mail.pop3_port ?? 0, - "local_smtp.pop3_port", - env, - ), - admin_email: envOverride("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", mail.admin_email, env), - sender_name: envOverride("SUPABASE_LOCAL_SMTP_SENDER_NAME", mail.sender_name, env), - }, - }; -}; +export const stackMajorVersionKey: StackEndpointKey = CliConfigKeys.db.majorVersion; const unsupportedConfigPaths = [ { path: "api.tls", active: (config: CliConfig) => config.api.enabled }, @@ -893,47 +221,31 @@ const configValidationError = (config: CliConfig): string | undefined => { return undefined; }; +const toStackConfigError = (cause: { readonly message: string }) => + new StackConfigError({ message: cause.message }); + const endpoint = (port: number | undefined): { readonly port: number | "auto" } => ({ port: port === undefined ? "auto" : port, }); -/** Loads and translates the effective project config for all stack commands. - * Pass `opts.context` to reuse an already-loaded project context. */ +/** Loads and translates the effective project config for all stack commands. */ export const loadStackConfig = Effect.fn("StackConfig.load")( - (projectRoot: string, opts?: { readonly context?: LocalProjectContext }): StackConfigEffect => + (projectRoot: string, opts?: { readonly projectRef?: string }): StackConfigEffect => Effect.gen(function* () { - const context = - opts?.context ?? - (yield* loadLocalProjectContext( - projectRoot, - (message) => new StackConfigError({ message }), - )); - const effectiveInput = yield* Effect.try({ - try: () => - resolveEffectiveCliConfig( - context.config, - context.loaded?.document, - context.projectEnvValues, - ), - catch: (cause) => - new StackConfigError({ - message: cause instanceof Error ? cause.message : "invalid config overrides", - }), - }); - const validatedConfig = yield* validateCliConfig(withoutUndefined(effectiveInput)).pipe( - Effect.mapError((cause) => { - const issues = SchemaIssue.makeFormatterStandardSchemaV1({ - leafHook: () => "Invalid value", - checkHook: () => undefined, - })(cause.issue).issues; - const path = issues[0]?.path - ?.map((segment) => String(typeof segment === "object" ? segment.key : segment)) - .join("."); - return new StackConfigError({ - message: path === undefined ? "invalid config" : `invalid config at ${path}`, - }); - }), + const { + resolvedConfig, + config: validatedConfig, + projectEnvValues, + document, + } = yield* loadResolvedConfigContext( + projectRoot, + Option.fromNullishOr(opts?.projectRef), + ).pipe( + Effect.mapError( + (cause) => new StackConfigError({ message: describeConfigLoadFailure(cause) }), + ), ); + const { originAt } = resolvedConfig.materialized; yield* recordOrioleDbTelemetry( validatedConfig.db.orioledb_version, validatedConfig.db.major_version, @@ -947,27 +259,28 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const externalProviders = yield* Effect.try({ try: () => - resolveAuthExternalProviders( - section(context.loaded?.document, "auth"), - validatedConfig.auth.external, - context.projectEnvValues, - ), + resolveAuthExternalProviders(section(document, "auth"), validatedConfig.auth.external), catch: (cause) => new StackConfigError({ message: cause instanceof Error ? cause.message : "invalid auth provider config", }), }); + const authExternalUrl = Option.getOrUndefined( + (yield* resolvedConfig + .get(CliConfigKeys.auth.externalUrl) + .pipe(Effect.mapError(toStackConfigError))).value, + ); const authConfig = yield* resolveAuthConfig( validatedConfig.auth, validatedConfig.local_smtp, { - authExternalUrl: resolveAuthExternalUrl( - context.loaded?.document, - context.projectEnvValues, - ), + smtpEnabled: resolveSmtpEnabled(resolvedConfig), + authExternalUrl, apiExternalUrl: validatedConfig.api.external_url, externalProviders, - ...resolveGotruePasskeyWebauthn(context.loaded?.document, context.projectEnvValues), + ...(yield* resolvePasskeyWebauthn(resolvedConfig).pipe( + Effect.mapError(toStackConfigError), + )), }, ); const path = yield* Path.Path; @@ -987,18 +300,12 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const localKeys = Effect.try({ try: () => { const configured = (value: string | undefined) => - value === undefined || value === "" - ? undefined - : decryptAuthSecret(value, context.projectEnvValues); + value === undefined || value === "" ? undefined : value; const publishableKey = configured(auth.publishable_key); const secretKey = configured(auth.secret_key); const configuredAnonKey = configured(auth.anon_key); const configuredServiceRoleKey = configured(auth.service_role_key); - const configuredSigningKeys = resolveConfiguredSigningKeys( - validatedConfig, - projectRoot, - context.projectEnvValues, - ); + const configuredSigningKeys = resolveConfiguredSigningKeys(validatedConfig, projectRoot); const signingKeys = configuredSigningKeys ?? (auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 @@ -1054,11 +361,8 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }); const functionEnvironments = Object.fromEntries( yield* Effect.forEach(Object.entries(validatedConfig.functions), ([name, config]) => - resolveCliConfigSubtree( - config.env, - { values: context.projectEnvValues }, - `functions.${name}.env`, - ).pipe( + resolveConfigSubtree(resolvedConfig, config.env, `functions.${name}.env`).pipe( + Effect.mapError((error) => new StackConfigError({ message: error.message })), Effect.map( (env) => [ @@ -1110,16 +414,20 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), catch: (cause) => new StackConfigError({ message: String(cause) }), }); - const functionsEnv = yield* Effect.try({ - try: () => - Object.fromEntries( - Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ - key, - decryptAuthSecret(value, context.projectEnvValues) ?? "", - ]), - ), - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); + const functionsEnvResult = decryptSecretMap( + Object.fromEntries( + Object.entries(validatedConfig.edge_runtime.secrets ?? {}).map(([key, value]) => [ + key, + value ?? "", + ]), + ), + resolvedConfig.dotenvPrivateKeys, + ); + if (!functionsEnvResult.ok) + return yield* new StackConfigError({ + message: `failed to parse config: ${functionsEnvResult.error}`, + }); + const functionsEnv = functionsEnvResult.value; const storageFileSizeLimit = yield* Effect.try({ try: () => String(parseFileSizeLimit(validatedConfig.storage.file_size_limit)), catch: (cause) => @@ -1139,68 +447,28 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }); const jwtSecret = configuredJwtSecret === undefined ? undefined : Redacted.make(configuredJwtSecret); - const document = context.loaded?.document; - const rootKey = yield* Effect.try({ - try: () => { - const raw = section(document, "db")?.root_key; - if (raw !== undefined && typeof raw !== "string") - throw new Error("db.root_key must be a string"); - const value = decryptAuthSecret( - envOverride("SUPABASE_DB_ROOT_KEY", raw, context.projectEnvValues), - context.projectEnvValues, - ); - return value === undefined || value === "" ? undefined : value; - }, - catch: (cause) => new StackConfigError({ message: String(cause) }), - }); - const dbPort = resolvePort( - DB_PORT, - document, - validatedConfig.db.port, - context.projectEnvValues, - ); - const apiPort = resolvePort( - API_PORT, - document, - validatedConfig.api.port, - context.projectEnvValues, - ); - const studioPort = resolvePort( - STUDIO_PORT, - document, - validatedConfig.studio.port, - context.projectEnvValues, - ); - const poolerPort = resolvePort( - DB_POOLER_PORT, - document, - validatedConfig.db.pooler.port, - context.projectEnvValues, - ); - const mailPort = resolvePort( - LOCAL_SMTP_PORT, - document, - validatedConfig.local_smtp.port, - context.projectEnvValues, + const rootKey = yield* resolvedConfig.get(CliConfigKeys.db.rootKey).pipe( + Effect.map(({ value, origin }) => + origin.tier === "default" || value === "" ? undefined : value, + ), + Effect.mapError(toStackConfigError), ); - const mailSmtpPort = resolvePort( - LOCAL_SMTP_SMTP_PORT, - document, + const configuredPort = (path: string, port: number) => + originAt(path).tier === "default" ? undefined : port; + const dbPort = configuredPort("db.port", validatedConfig.db.port); + const apiPort = configuredPort("api.port", validatedConfig.api.port); + const studioPort = configuredPort("studio.port", validatedConfig.studio.port); + const poolerPort = configuredPort("db.pooler.port", validatedConfig.db.pooler.port); + const mailPort = configuredPort("local_smtp.port", validatedConfig.local_smtp.port); + const mailSmtpPort = configuredPort( + "local_smtp.smtp_port", validatedConfig.local_smtp.smtp_port ?? 0, - context.projectEnvValues, ); - const mailPop3Port = resolvePort( - LOCAL_SMTP_POP3_PORT, - document, + const mailPop3Port = configuredPort( + "local_smtp.pop3_port", validatedConfig.local_smtp.pop3_port ?? 0, - context.projectEnvValues, - ); - const analyticsPort = resolvePort( - ANALYTICS_PORT, - document, - validatedConfig.analytics.port, - context.projectEnvValues, ); + const analyticsPort = configuredPort("analytics.port", validatedConfig.analytics.port); const poolMode = validatedConfig.db.pooler.pool_mode === "session" ? ("session" as const) : "transaction"; const storagePath = `${projectRoot}/supabase/.temp/stack-uploads`; @@ -1347,11 +615,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( endpoints: { http: endpoint(apiPort), inspector: endpoint( - resolvePort( - EDGE_RUNTIME_INSPECTOR_PORT, - document, + configuredPort( + "edge_runtime.inspector_port", validatedConfig.edge_runtime.inspector_port, - context.projectEnvValues, ), ), }, @@ -1398,10 +664,10 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( return { creations: createCreations, source: validatedConfig, - projectEnvValues: context.projectEnvValues, + projectEnvValues, + originAt, remoteJwks, keys, - ...(context.loaded?.document === undefined ? {} : { document: context.loaded.document }), }; }), ); diff --git a/apps/cli/src/command-internal/stack-functions-env.ts b/apps/cli/src/command-internal/stack-functions-env.ts index 27f55fe62f..4d7c33290e 100644 --- a/apps/cli/src/command-internal/stack-functions-env.ts +++ b/apps/cli/src/command-internal/stack-functions-env.ts @@ -1,6 +1,6 @@ import { Data, Effect, FileSystem, Predicate } from "effect"; import type { ServiceCreationInput } from "@supabase/stack/effect"; -import { parseDotEnv } from "./dotenv.ts"; +import { parseDotEnv } from "../shared/config/dotenv.ts"; import { actionability, type CliErrorActionabilityDeclaration, diff --git a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts index 2cd088a0e5..f2c402d63f 100644 --- a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts +++ b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts @@ -28,6 +28,7 @@ import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; import { ExperimentalFlag, YesFlag } from "./global-flags.ts"; import { stackStart } from "../commands/experimental/stack/start/start.handler.ts"; import { destroyTestStacks } from "../../tests/helpers/stack-cleanup.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; const projectConfig = ` project_id = "stack-storage-native-integration" @@ -80,6 +81,7 @@ const makeLayers = (root: string) => { const tty = mockTty({ stdinIsTty: false, stdoutIsTty: false }); return Layer.mergeAll( BunServices.layer, + cliConfigValuesTestLayer, FetchHttpClient.layer, runtimeInfoLayer, settings, diff --git a/apps/cli/src/command-internal/status-errors.ts b/apps/cli/src/command-internal/status-errors.ts index 3da9741a59..25d39b85cd 100644 --- a/apps/cli/src/command-internal/status-errors.ts +++ b/apps/cli/src/command-internal/status-errors.ts @@ -17,7 +17,7 @@ export class StatusWorkdirError extends Data.TaggedError("StatusWorkdirError")<{ } } -/** `loadCliConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ +/** The config load rejected `supabase/config.toml` (malformed TOML/JSON). */ export class StatusConfigLoadError extends Data.TaggedError("StatusConfigLoadError")<{ readonly message: string; }> { diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index 7d8cf61dc9..074ee335ef 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -2,11 +2,7 @@ import type { CliConfig } from "@supabase/config"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { serviceContainerIds } from "./docker-ids.ts"; -import { - envOverrideBool, - resolveLocalConfigValues, - type LocalConfigValues, -} from "./local-config-values.ts"; +import { resolveLocalConfigValues, type LocalConfigValues } from "./local-config-values.ts"; /** * A status output field: the dotted key `--override-name =` matches against @@ -230,15 +226,10 @@ export interface StatusLocalState { /** * Resolves local config values (URLs, keys — can throw, see {@link resolveLocalConfigValues}) * and the per-service `.enabled` gates, with no reference to `excluded`/`containerIds` — see - * {@link gateStatusState} for the Docker-dependent half this composes with. Each `.enabled` - * gate is read through {@link envOverrideBool}, not the raw decoded `config.
.enabled`, - * so an env-overridden stack's running services match what `status` reports. + * {@link gateStatusState} for the Docker-dependent half this composes with. `config` must be + * the resolved config's materialized config so env overrides are already applied to every gate. * * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't - * parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} 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 generateAsymmetricLocalJwt}. */ @@ -259,52 +250,15 @@ export function resolveStatusLocalState( */ precomputedLocal?: LocalConfigValues, ): StatusLocalState { - const local = - precomputedLocal ?? - resolveLocalConfigValues(config, hostname, workdir, projectEnvValues, document); + const local = precomputedLocal ?? resolveLocalConfigValues(config, hostname, workdir, document); - const apiEnabled = envOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const studioSectionEnabled = envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const authSectionEnabled = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const inbucketSectionEnabled = envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const storageSectionEnabled = envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ); - const edgeRuntimeEnabled = envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ); - const storageS3ProtocolEnabled = envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ); + const apiEnabled = config.api.enabled; + const studioSectionEnabled = config.studio.enabled; + const authSectionEnabled = config.auth.enabled; + const inbucketSectionEnabled = config.local_smtp.enabled; + const storageSectionEnabled = config.storage.enabled; + const edgeRuntimeEnabled = config.edge_runtime.enabled; + const storageS3ProtocolEnabled = config.storage.s3_protocol.enabled; return { config, diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index a5793494f8..ef4d7007d1 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -440,166 +440,6 @@ describe("statusValues", () => { }); }); - describe("SUPABASE_*_ENABLED env overrides", () => { - // `resolveStatusLocalState` must read the post-env-override value for every `.enabled` - // 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 } = statusValues( - config, - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { - SUPABASE_API_ENABLED: "true", - }, - ); - expect(values.API_URL).toBeDefined(); - expect(values.REST_URL).toBeDefined(); - }); - - it("omits REST_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { - const { values } = statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - NONE, - NO_OVERRIDES, - WORKDIR, - { SUPABASE_API_ENABLED: "false" }, - ); - expect(values.REST_URL).toBeUndefined(); - expect(values.API_URL).toBeDefined(); - }); - - it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { - const config = baseConfig({ studio: { enabled: false } }); - const { values } = statusValues( - 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", () => { - const config = baseConfig({ auth: { enabled: false } }); - const { values } = statusValues( - 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 } = statusValues( - 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 } = statusValues( - 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 } = statusValues( - 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 } = statusValues( - 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 } = statusValues( - 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 } = statusValues( - 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"]]); diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index 1fa23f56aa..62ca0730ad 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -1,20 +1,17 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Result } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { pickCliEnvName } from "../config/cli-config-key.ts"; +import { CliEnvNames } from "../config/cli-config-keys.ts"; +import { readShellEnvironment } from "../shared/config/cli-config-env.ts"; import { resolveApiExternalUrl } from "./api-url.ts"; import { validateApiPort, validateApiTlsPresence } from "./config-validate.ts"; -import { loadProjectEnv } from "./db-config.toml-read.ts"; +import { describeConfigLoadFailure, loadResolvedConfigContext } from "./resolved-config-context.ts"; import { mapTenantApiKeysError } from "./get-tenant-api-keys.ts"; import { generateLocalJwt } from "./local-jwt.ts"; import { getHostname } from "./hostname.ts"; -import { - decryptAuthSecret, - envOverride, - envOverrideBool, - envOverridePort, - resolveJwtSecret, -} from "./local-config-values.ts"; +import { resolveJwtSecret } from "./local-config-values.ts"; import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts"; import { extractServiceKeys } from "./tenant-keys.ts"; import { @@ -66,21 +63,27 @@ export interface StorageCredentials { export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { readonly projectRef: string; - readonly config: StorageConfigView; - /** - * Already-resolved project env map for the `SUPABASE_API_*`/`SUPABASE_AUTH_*` - * overrides, when the caller has one in scope. When omitted, this loads the - * project dotenv itself. - */ - readonly projectEnvValues?: Readonly>; }) { const cliSettings = yield* CommandSettings; if (opts.projectRef !== "") { const baseUrl = `https://${opts.projectRef}.${cliSettings.projectHost}`; - const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - if (envKey !== undefined && envKey.length > 0) { - return { baseUrl, apiKey: envKey, localKongCa: undefined } satisfies StorageCredentials; + const shell = yield* readShellEnvironment({ + names: [CliEnvNames.authServiceRoleKey.name], + }).pipe(Effect.mapError(toStorageConfigError)); + const envKey = yield* Result.match( + pickCliEnvName(CliEnvNames.authServiceRoleKey, { shell: shell.get }), + { + onFailure: (error) => Effect.fail(toStorageConfigError(error)), + onSuccess: Effect.succeed, + }, + ); + if (Option.isSome(envKey)) { + return { + baseUrl, + apiKey: envKey.value, + localKongCa: undefined, + } satisfies StorageCredentials; } // Resolved lazily so the local path never triggers auth. const api = yield* (yield* CommandPlatformApiFactory).make; @@ -111,19 +114,15 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const projectEnvValues = - opts.projectEnvValues ?? - (yield* loadProjectEnv(fs, path, cliSettings.workdir).pipe( - Effect.mapError((cause) => new StorageConfigError({ message: cause.message })), - )); - const api = yield* resolveLocalApiConfig(opts.config.api, projectEnvValues); + const { config, resolvedConfig } = yield* loadLocalStorageConfig(cliSettings.workdir); + const api = yield* resolveLocalApiConfig(config.api); const baseUrl = resolveApiExternalUrl( api, - yield* getHostname(projectEnvValues).pipe( + yield* getHostname(resolvedConfig).pipe( Effect.mapError((cause) => new StorageConfigError({ message: cause.message })), ), ); - const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth, projectEnvValues); + const apiKey = yield* resolveLocalServiceRoleKey(config.auth); // Validate the cert/key pairing only when the API and TLS are both enabled; // inject a CA whenever the resolved URL is https. @@ -145,8 +144,7 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { }); /** - * Converts a thrown config-load validation error (from `envOverride*`, - * `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) into a tagged + * Converts a thrown config-load validation error (from `resolveJwtSecret`, `validateApi*`) into a tagged * `StorageConfigError`, preserving the original message. */ const toStorageConfigError = (cause: unknown) => @@ -154,90 +152,50 @@ const toStorageConfigError = (cause: unknown) => message: cause instanceof Error ? cause.message : String(cause), }); -/** - * Folds `SUPABASE_API_*` overrides into `[api]` before deriving the gateway - * URL, so a stack started with an overridden port stays reachable here - * instead of falling back to the raw `config.toml` value. - */ -const resolveLocalApiConfig = ( - api: StorageConfigView["api"], - projectEnvValues: Readonly>, -) => +const loadLocalStorageConfig = (workdir: string) => + loadResolvedConfigContext(workdir).pipe( + Effect.mapError( + (cause) => new StorageConfigError({ message: describeConfigLoadFailure(cause) }), + ), + ); + +/** The effective `[api]` view, with the port and TLS pairing checked, for deriving the gateway URL. */ +const resolveLocalApiConfig = (api: StorageConfigView["api"]) => Effect.try({ try: () => { - const resolved = { - enabled: envOverrideBool( - "SUPABASE_API_ENABLED", - api.enabled, - "api.enabled", - projectEnvValues, - ), - external_url: envOverride("SUPABASE_API_EXTERNAL_URL", api.external_url, projectEnvValues), - port: envOverridePort("SUPABASE_API_PORT", api.port, "api.port", projectEnvValues), - tls: { - enabled: envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - cert_path: envOverride("SUPABASE_API_TLS_CERT_PATH", api.tls.cert_path, projectEnvValues), - key_path: envOverride("SUPABASE_API_TLS_KEY_PATH", api.tls.key_path, projectEnvValues), - }, - } satisfies StorageConfigView["api"]; - validateApiPort(resolved.enabled, resolved.port); - return resolved; + validateApiPort(api.enabled, api.port); + return api; }, catch: toStorageConfigError, }); /** * Resolves the service-role key for the local Storage gateway: - * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` → `auth.jwt_secret` → - * `defaultJwtSecret`, rejected if shorter than 16 chars. - * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` → - * `auth.service_role_key` → signed from the resolved jwt secret. + * - jwt secret: `auth.jwt_secret` → `defaultJwtSecret`, rejected if shorter than 16 chars. + * - service-role key: `auth.service_role_key` → signed from the resolved jwt secret. * * An explicit `service_role_key = ""` is treated as unset and regenerated. */ -const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( - auth: StorageConfigView["auth"], - projectEnvValues: Readonly>, -) { +const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: StorageConfigView["auth"]) { const jwtSecret = yield* Effect.try({ - try: () => - resolveJwtSecret( - decryptAuthSecret( - envOverride("SUPABASE_AUTH_JWT_SECRET", auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ), - catch: toStorageConfigError, - }); - const configuredKey = yield* Effect.try({ - try: () => - decryptAuthSecret( - envOverride("SUPABASE_AUTH_SERVICE_ROLE_KEY", auth.service_role_key, projectEnvValues), - projectEnvValues, - ), + try: () => resolveJwtSecret(auth.jwt_secret), catch: toStorageConfigError, }); + const configuredKey = auth.service_role_key; return configuredKey !== undefined && configuredKey.length > 0 ? configuredKey : generateLocalJwt(jwtSecret, "service_role"); }); /** - * Runs the local config-load validations (API overrides, auth secret - * decryption, TLS presence) without building credentials, for `seed - * buckets`'s empty-config short-circuit. + * Runs the local config-load validations (API port, auth secrets, TLS presence) without building + * credentials, for `seed buckets`'s empty-config short-circuit. */ -export const validateLocalStorageConfig = Effect.fnUntraced(function* ( - config: StorageConfigView, - projectEnvValues: Readonly>, -) { - const api = yield* resolveLocalApiConfig(config.api, projectEnvValues); - yield* resolveLocalServiceRoleKey(config.auth, projectEnvValues); +export const validateLocalStorageConfig = Effect.fnUntraced(function* () { + const cliSettings = yield* CommandSettings; + const { config } = yield* loadLocalStorageConfig(cliSettings.workdir); + const api = yield* resolveLocalApiConfig(config.api); + yield* resolveLocalServiceRoleKey(config.auth); if (api.enabled && api.tls.enabled) { yield* Effect.try({ try: () => validateApiTlsPresence(api.tls.cert_path, api.tls.key_path), diff --git a/apps/cli/src/command-internal/supabase-env.ts b/apps/cli/src/command-internal/supabase-env.ts index fea451fc10..b4016143b6 100644 --- a/apps/cli/src/command-internal/supabase-env.ts +++ b/apps/cli/src/command-internal/supabase-env.ts @@ -6,7 +6,9 @@ * so callers OR the parsed flag value with this read (flag-set wins). */ -import { Config, Effect, Option } from "effect"; +import { Effect } from "effect"; + +import { envValue } from "../shared/config/env-option.ts"; const TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); @@ -15,14 +17,8 @@ function parseEnvBool(raw: string | undefined): boolean { return raw !== undefined && TRUE_VALUES.has(raw); } -/** Env-record providers never fail this read, so a `ConfigError` here is a defect. */ -const shellEnv = (name: string) => - Config.option(Config.String(name)).pipe(Effect.map(Option.getOrUndefined), Effect.orDie); - -/** Reads a single `SUPABASE_*` boolean env var through `Config` (see module doc). */ -export const supabaseEnvBool = Effect.fnUntraced(function* (name: string) { - return parseEnvBool(yield* shellEnv(name)); -}); +/** Reads a single `SUPABASE_*` boolean env var from the ambient config provider (see module doc). */ +export const supabaseEnvBool = (name: string) => Effect.map(envValue(name), parseEnvBool); /** * Resolves a `SUPABASE_*` boolean where a project `supabase/.env` value may @@ -32,15 +28,15 @@ export const supabaseEnvBool = Effect.fnUntraced(function* (name: string) { * project env, letting an opt-out gate default on while any present value * still disables. */ -export const supabaseEnvBoolWithProjectFallback = Effect.fnUntraced(function* ( +export const supabaseEnvBoolWithProjectFallback = ( name: string, projectEnv: Record, opts: { readonly whenUnset?: boolean } = {}, -) { - const raw = (yield* shellEnv(name)) ?? projectEnv[name]; - if (raw === undefined) return opts.whenUnset ?? false; - return parseEnvBool(raw); -}); +) => + Effect.map(envValue(name), (shell) => { + const raw = shell ?? projectEnv[name]; + return raw === undefined ? (opts.whenUnset ?? false) : parseEnvBool(raw); + }); /** * Resolves a `SUPABASE_*` string with the same shell-presence-wins semantics @@ -48,9 +44,7 @@ export const supabaseEnvBoolWithProjectFallback = Effect.fnUntraced(function* ( * the raw merged string, or `""` when absent from both. `??` (not `||`) * encodes the presence check. */ -export const supabaseEnvStringWithProjectFallback = Effect.fnUntraced(function* ( +export const supabaseEnvStringWithProjectFallback = ( name: string, projectEnv: Record, -) { - return (yield* shellEnv(name)) ?? projectEnv[name] ?? ""; -}); +) => Effect.map(envValue(name), (shell) => shell ?? projectEnv[name] ?? ""); diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 0180e26e4f..557eda1c63 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -38,7 +38,6 @@ const DISABLE_PGTAP = "drop extension if exists pgtap"; // Compose pin: the config schema has no `[images]` override. Stack prove uses // catalog `pg_prove` instead. Re-verify `NO_TESTS_VERDICT` when bumping this tag. const PG_PROVE_IMAGE = "supabase/pg_prove:3.36"; -const MAX_PROJECT_ID_LENGTH = 40; /** * `pg_prove` exits 0 even when it finds nothing to run, so a typo'd path or a * misresolved bind can silently report success on zero tests. Detecting @@ -105,13 +104,6 @@ const managedStackFor = Effect.fn("test.db.managedStack")(function* () { return { stack, database, connection, major } satisfies ManagedStack; }); -function sanitizeProjectId(src: string): string { - return src - .replace(/[^a-zA-Z0-9_.-]+/g, "_") - .replace(/^[_.-]+/, "") - .slice(0, MAX_PROJECT_ID_LENGTH); -} - export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; @@ -215,13 +207,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { : isLocal && backend.kind !== "stack" ? yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); - // The project id is sanitized unconditionally before deriving the - // network name, so a configured `project_id` like "my project" joins - // the same sanitized network the local stack created. - const projectId = sanitizeProjectId( - Option.getOrElse(toml.projectId, () => nodePath.basename(cliSettings.workdir)), - ); - return { _tag: "named" as const, name: `supabase_network_${projectId}` }; + return { _tag: "named" as const, name: `supabase_network_${toml.projectId}` }; }) : { _tag: "host" as const }; diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 679ae17107..8b6ccaae37 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -10,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; @@ -258,6 +259,7 @@ function setup(opts: SetupOpts = {}) { const docker = mockDockerRun(opts); const bundled = mockBundledPostgresClient(opts); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, out.layer, resolver.layer, connection.layer, diff --git a/apps/cli/src/command-internal/test-db.layers.unit.test.ts b/apps/cli/src/command-internal/test-db.layers.unit.test.ts index b1038fa719..db2062ca89 100644 --- a/apps/cli/src/command-internal/test-db.layers.unit.test.ts +++ b/apps/cli/src/command-internal/test-db.layers.unit.test.ts @@ -23,6 +23,7 @@ import { mockTelemetryStateLayer, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { @@ -83,6 +84,7 @@ function ambientStubs() { out.layer, flagLayers, mockTelemetryStateLayer, + cliConfigValuesTestLayer, heavyServiceStubs, ); } diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 25e142532a..01cd33a2ed 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -27,6 +27,7 @@ import type { InitializationCommandOptions, PostgresCommandOptions } from "@supa import type { Stack } from "@supabase/stack/effect"; import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; +import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -124,6 +125,7 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { }), stackBackendLayer("stack"), dbConnectionLayer, + cliConfigValuesTestLayer, ); const assertPgtapDropped = Effect.gen(function* () { const dbConnection = yield* DbConnection; diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json new file mode 100644 index 0000000000..55cf899a7b --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json new file mode 100644 index 0000000000..7ad4bb8138 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json new file mode 100644 index 0000000000..55cf899a7b --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json new file mode 100644 index 0000000000..c9beabc890 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 56001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json new file mode 100644 index 0000000000..718f334759 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json @@ -0,0 +1,50 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "dotenv_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 57001, + "projectEnv": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_DB_MIGRATIONS_ENABLED": "false", + "SUPABASE_DB_PORT": "57001", + "SUPABASE_DB_SEED_ENABLED": "false" + }, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json new file mode 100644 index 0000000000..c9beabc890 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 56001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json new file mode 100644 index 0000000000..05857cdf83 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "shell_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/shell-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 56001, + "projectEnv": {}, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json new file mode 100644 index 0000000000..b92d9dde59 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json @@ -0,0 +1,50 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "dotenv_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 57001, + "projectEnv": { + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_DB_MIGRATIONS_ENABLED": "false", + "SUPABASE_DB_PORT": "57001", + "SUPABASE_DB_SEED_ENABLED": "false" + }, + "projectId": "abcdefghijklmnopqrst", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json new file mode 100644 index 0000000000..3ad1b199d6 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "zyxwvutsrqponmlkjihg", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json new file mode 100644 index 0000000000..3ad1b199d6 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "zyxwvutsrqponmlkjihg", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json new file mode 100644 index 0000000000..55cf899a7b --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json new file mode 100644 index 0000000000..acbdf90332 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": false, + "storageEnabled": false, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "golden-base", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json new file mode 100644 index 0000000000..b579052d57 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": "prod", + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": false, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": false, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/remote-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "shell-project-id", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": false, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json new file mode 100644 index 0000000000..afff4284fc --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json @@ -0,0 +1,45 @@ +{ + "ok": { + "apiSchemas": [ + "public", + "base_api" + ], + "appliedRemote": null, + "baseline": { + "apiAutoExposeNewTables": null, + "authEnabled": true, + "realtimeEnabled": true, + "storageEnabled": true, + "vaultNames": [] + }, + "denoVersion": 2, + "majorVersion": 17, + "migrationsEnabled": true, + "orioledbVersion": null, + "password": "postgres", + "pgDelta": { + "declarativeSchemaPath": "supabase/base-declarative", + "enabled": false, + "formatOptions": null + }, + "poolerConnectionString": null, + "port": 55001, + "projectEnv": {}, + "projectId": "shell-project-id", + "schemaPathPatterns": [ + "./schemas/*.sql" + ], + "schemaPaths": [ + "supabase/schemas/*.sql" + ], + "seed": { + "enabled": true, + "sqlPaths": [ + "supabase/seeds/*.sql" + ] + }, + "shadowPort": 55002, + "vault": [], + "webhooksEnabled": false + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json new file mode 100644 index 0000000000..14e5c5dc26 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/no-overrides.json @@ -0,0 +1,712 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,base_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": false, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://base.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "ipVersion": "IPv4", + "maxHeaderLength": 4096 + }, + "endpoints": { + "http": { + "port": 55321 + }, + "rpc": { + "port": "auto" + } + }, + "service": "realtime" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,base_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json new file mode 100644 index 0000000000..034299bb78 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/project-dotenv.json @@ -0,0 +1,681 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 57322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,dotenv_api" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://dotenv.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 57321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": { + "SUPABASE_API_PORT": "57321", + "SUPABASE_API_SCHEMAS": "public,dotenv_api", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_AUTH_SITE_URL": "http://dotenv.example.com", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STUDIO_ENABLED": "false" + }, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 57321, + "schemas": [ + "public", + "dotenv_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://dotenv.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json new file mode 100644 index 0000000000..ca7de557e0 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-ignored.json @@ -0,0 +1,1126 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,base_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": false, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://base.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "ipVersion": "IPv4", + "maxHeaderLength": 4096 + }, + "endpoints": { + "http": { + "port": 55321 + }, + "rpc": { + "port": "auto" + } + }, + "service": "realtime" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,base_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "base_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": true, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://base.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": true, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": { + "prod": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 54321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false + }, + "custom_access_token": { + "enabled": false + }, + "mfa_verification_attempt": { + "enabled": false + }, + "password_verification_attempt": { + "enabled": false + }, + "send_email": { + "enabled": false + }, + "send_sms": { + "enabled": false + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 54322, + "seed": { + "enabled": true, + "sql_paths": [ + "./seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 54323 + } + } + }, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json new file mode 100644 index 0000000000..405b3322ba --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context-with-shell-env.json @@ -0,0 +1,673 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 56322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 56321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 56321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json new file mode 100644 index 0000000000..788f1a6061 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/remote-block-merged-via-context.json @@ -0,0 +1,697 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 55322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "pgmeta" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,remote_api" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://remote.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 55321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 55321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": { + "apiExtraSearchPath": "public,extensions", + "apiMaxRows": 1000, + "apiSchemas": "public,remote_api", + "cliVersion": "0.0.0-dev", + "snippetsRoot": "/supabase/snippets" + }, + "endpoints": { + "http": { + "port": 55323 + } + }, + "service": "studio" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 55321, + "schemas": [ + "public", + "remote_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://remote.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 55322, + "seed": { + "enabled": false, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "abcdefghijklmnopqrst", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": true, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json new file mode 100644 index 0000000000..b2fdf27b39 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-beats-dotenv.json @@ -0,0 +1,679 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 57322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 57321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 57321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": { + "SUPABASE_API_PORT": "57321", + "SUPABASE_AUTH_ENABLE_SIGNUP": "false", + "SUPABASE_DB_PORT": "57322", + "SUPABASE_REALTIME_ENABLED": "false", + "SUPABASE_STUDIO_ENABLED": "false" + }, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 57321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 57322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json new file mode 100644 index 0000000000..7e20a8e533 --- /dev/null +++ b/apps/cli/src/command-internal/testdata/config-precedence/stack-config/shell-env.json @@ -0,0 +1,673 @@ +{ + "creations": [ + { + "config": { + "healthTimeoutMs": 120000, + "jwtExpiry": 3600, + "settings": {}, + "version": "17" + }, + "endpoints": { + "sql": { + "port": 56322 + } + }, + "service": "database" + }, + { + "config": { + "apiKey": "api-key", + "backend": "postgres" + }, + "endpoints": { + "http": { + "port": "auto" + } + }, + "service": "analytics" + }, + { + "config": { + "extraSearchPath": "public,extensions", + "maxRows": 1000, + "schemas": "public,shell_api" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "rest" + }, + { + "config": { + "disableSignup": true, + "jwtExpiry": 3600, + "settings": { + "additionalRedirectUrls": "https://127.0.0.1:3000", + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "template": {} + }, + "enableAnonymousSignIns": false, + "enableManualLinking": false, + "enableRefreshTokenRotation": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hooks": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimumPasswordLength": 6, + "oauthServer": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "passwordRequirements": "", + "rateLimit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refreshTokenReuseInterval": 10, + "sessions": {}, + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "siteUrl": "http://shell.example.com", + "smtpAdminEmail": "admin@email.com", + "smtpSenderName": "Admin" + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "auth" + }, + { + "config": { + "filePath": "/supabase/.temp/stack-uploads/golden-stack", + "fileSizeLimit": "52428800", + "s3AccessKeyId": "625729a08b95bf1b7ff351a663f3a23c", + "s3ProtocolEnabled": true, + "s3Region": "local", + "s3SecretAccessKey": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "vectorEnabled": true, + "vectorMaxBuckets": 10, + "vectorMaxIndexes": 5 + }, + "endpoints": { + "http": { + "port": 56321 + } + }, + "service": "storage" + }, + { + "config": { + "env": {}, + "filesRoot": "", + "functions": {}, + "functionsRoot": "/supabase/functions", + "policy": "per_worker", + "verifyJwt": true + }, + "endpoints": { + "http": { + "port": 56321 + }, + "inspector": { + "port": "auto" + } + }, + "service": "functions" + }, + { + "config": {}, + "endpoints": { + "http": { + "port": "auto" + }, + "pop3": { + "port": "auto" + }, + "smtp": { + "port": "auto" + } + }, + "service": "mail" + } + ], + "keys": { + "anonKeyIsOverride": false, + "serviceRoleKeyIsOverride": false + }, + "projectEnvValues": {}, + "source": { + "analytics": { + "backend": "postgres", + "enabled": true, + "port": 54327 + }, + "api": { + "enabled": true, + "extra_search_path": [ + "public", + "extensions" + ], + "max_rows": 1000, + "port": 56321, + "schemas": [ + "public", + "shell_api" + ], + "tls": { + "enabled": false + } + }, + "auth": { + "additional_redirect_urls": [ + "https://127.0.0.1:3000" + ], + "captcha": { + "enabled": false + }, + "email": { + "double_confirm_changes": true, + "enable_confirmations": false, + "enable_signup": true, + "max_frequency": "1s", + "notification": {}, + "otp_expiry": 3600, + "otp_length": 6, + "secure_password_change": false, + "smtp": { + "enabled": false + }, + "template": {} + }, + "enable_anonymous_sign_ins": false, + "enable_manual_linking": false, + "enable_refresh_token_rotation": true, + "enable_signup": false, + "enabled": true, + "external": { + "apple": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "azure": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "bitbucket": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "discord": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "facebook": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "figma": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "github": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "gitlab": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "https://gitlab.com" + }, + "google": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "kakao": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "keycloak": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "linkedin_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "notion": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "slack_oidc": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "spotify": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitch": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "twitter": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "workos": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "x": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + }, + "zoom": { + "client_id": "", + "email_optional": false, + "enabled": false, + "redirect_uri": "", + "skip_nonce_check": false, + "url": "" + } + }, + "hook": { + "before_user_created": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "custom_access_token": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "mfa_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "password_verification_attempt": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_email": { + "enabled": false, + "secrets": "", + "uri": "" + }, + "send_sms": { + "enabled": false, + "secrets": "", + "uri": "" + } + }, + "jwt_expiry": 3600, + "mfa": { + "max_enrolled_factors": 10, + "phone": { + "enroll_enabled": false, + "max_frequency": "5s", + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "verify_enabled": false + }, + "totp": { + "enroll_enabled": false, + "verify_enabled": false + }, + "web_authn": { + "enroll_enabled": false, + "verify_enabled": false + } + }, + "minimum_password_length": 6, + "oauth_server": { + "allow_dynamic_registration": false, + "authorization_url_path": "/oauth/consent", + "enabled": false + }, + "password_requirements": "", + "rate_limit": { + "anonymous_users": 30, + "email_sent": 2, + "sign_in_sign_ups": 30, + "sms_sent": 30, + "token_refresh": 150, + "token_verifications": 30, + "web3": 30 + }, + "refresh_token_reuse_interval": 10, + "sessions": {}, + "site_url": "http://shell.example.com", + "sms": { + "enable_confirmations": false, + "enable_signup": false, + "max_frequency": "5s", + "messagebird": { + "enabled": false + }, + "otp_expiry": 60, + "otp_length": 6, + "template": "Your code is {{ .Code }}", + "textlocal": { + "enabled": false + }, + "twilio": { + "account_sid": "", + "enabled": false, + "message_service_sid": "" + }, + "twilio_verify": { + "enabled": false + }, + "vonage": { + "enabled": false + } + }, + "third_party": { + "auth0": { + "enabled": false + }, + "aws_cognito": { + "enabled": false + }, + "clerk": { + "enabled": false + }, + "firebase": { + "enabled": false + }, + "workos": { + "enabled": false + } + }, + "web3": { + "ethereum": { + "enabled": false + }, + "solana": { + "enabled": false + } + } + }, + "compute": {}, + "db": { + "health_timeout": "2m", + "major_version": 17, + "migrations": { + "enabled": true, + "schema_paths": [] + }, + "network_restrictions": { + "allowed_cidrs": [ + "0.0.0.0/0" + ], + "allowed_cidrs_v6": [ + "::/0" + ], + "enabled": false + }, + "pooler": { + "default_pool_size": 20, + "enabled": false, + "max_client_conn": 100, + "pool_mode": "transaction", + "port": 54329 + }, + "port": 56322, + "seed": { + "enabled": true, + "sql_paths": [ + "supabase/seed.sql" + ] + }, + "settings": {}, + "shadow_port": 54320, + "ssl_enforcement": { + "enabled": false + } + }, + "edge_runtime": { + "deno_version": 2, + "enabled": true, + "inspector_port": 8083, + "policy": "per_worker" + }, + "experimental": { + "inspect": { + "rules": [] + }, + "pgdelta": { + "enabled": true + }, + "webhooks": { + "enabled": false + } + }, + "functions": {}, + "local_smtp": { + "enabled": true, + "port": 54324 + }, + "project_id": "golden-stack", + "realtime": { + "enabled": false, + "ip_version": "IPv4", + "max_header_length": 4096 + }, + "remotes": {}, + "storage": { + "analytics": { + "buckets": {}, + "enabled": false, + "max_catalogs": 2, + "max_namespaces": 5, + "max_tables": 10 + }, + "enabled": true, + "file_size_limit": "50MiB", + "image_transformation": { + "enabled": false + }, + "s3_protocol": { + "enabled": true + }, + "vector": { + "buckets": {}, + "enabled": true, + "max_buckets": 10, + "max_indexes": 5 + } + }, + "studio": { + "api_url": "http://127.0.0.1", + "enabled": false, + "port": 55323 + } + } +} diff --git a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts index 70f4cad89b..2035b44065 100644 --- a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts @@ -6,6 +6,7 @@ import { stripVTControlCharacters } from "node:util"; import { Effect } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { upgradeNoticeHook } from "./upgrade-notice.ts"; describe("upgrade notice user-level cache", () => { @@ -61,7 +62,7 @@ describe("upgrade notice user-level cache", () => { fetchCalls += 1; return Promise.resolve(latestTag); }, - ), + ).pipe(Effect.provide(cliConfigProviderLayer)), ); } finally { process.stderr.write = realWrite; diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index 528412d329..e72715b3c7 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -20,16 +20,24 @@ import { rootFlagTokens, } from "../shared/cli/run.ts"; import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION, parseSemver } from "../shared/cli/version.ts"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; +import { candidateDotenvFilenames, readShellEnvironment } from "../shared/config/cli-config-env.ts"; +import { readSupabaseHome } from "../shared/config/supabase-home.ts"; import { bold, yellow } from "./colors.ts"; -import { parseDotEnv } from "./dotenv.ts"; -import { candidateDotenvFilenames } from "./project-environment.ts"; +import { parseDotEnv } from "../shared/config/dotenv.ts"; const LATEST_RELEASE_URL = "https://api.github.com/repos/supabase/cli/releases/latest"; const CACHE_TTL_MS = 10 * 60 * 60 * 1000; /** Bounds this pre-exit hook's latency. */ const FETCH_TIMEOUT_MS = 800; +const UPGRADE_NOTICE_ENV_NAMES = [ + "SUPABASE_NO_UPDATE_NOTIFIER", + "SUPABASE_WORKDIR", + "SUPABASE_ENV", + "SUPABASE_DEBUG", + "GITHUB_TOKEN", +]; + /** Recognized "true" spellings; anything else, including garbage, leaves the notifier on. */ const PARSE_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); @@ -160,7 +168,12 @@ function resolveNoticeBaseDir( } let current = cwd; while (true) { - if (existsSync(join(current, "supabase", "config.toml"))) return current; + if ( + existsSync(join(current, "supabase", "config.json")) || + existsSync(join(current, "supabase", "config.toml")) + ) { + return current; + } const parent = dirname(current); if (parent === current) return cwd; current = parent; @@ -170,7 +183,7 @@ function resolveNoticeBaseDir( /** * The project dotenv chain as a merged map: `/supabase` then ``, * first file to define a key wins, shell env always beats a chain value — - * same precedence as `resolveProjectEnvironmentValues`. Read for every real + * same precedence as `loadCliProjectEnvFiles`. Read for every real * command since this hook can't tell whether the command loads config; the * only effect is a suppressed notice or extra debug diagnostic either way. */ @@ -184,8 +197,10 @@ async function projectDotenvValues( const dirs = dirname(base) === base ? [join(base, "supabase")] : [join(base, "supabase"), base]; for (const dir of dirs) { for (const filename of candidateDotenvFilenames(env["SUPABASE_ENV"] || "development")) { - const contents = await readFile(join(dir, filename), "utf8").catch(() => undefined); - if (contents === undefined) continue; + const bytes = await readFile(join(dir, filename)).catch(() => undefined); + if (bytes === undefined) continue; + // TextDecoder drops a leading byte-order mark, as the main loader's reader does. + const contents = new TextDecoder().decode(bytes); try { for (const [key, value] of Object.entries(parseDotEnv(contents))) { if (!(key in merged)) merged[key] = value; @@ -313,10 +328,12 @@ export async function runUpgradeNotice( return { cache: cachePathIsSafe ? (inProject ? "project" : "user") : "disabled", cacheFresh }; } -async function fetchLatestReleaseTag(): Promise { +async function fetchLatestReleaseTag( + env: Readonly>, +): Promise { // Authenticates when GITHUB_TOKEN is set, for the higher rate limit on // shared-egress CI runners. - const token = process.env["GITHUB_TOKEN"]; + const token = env["GITHUB_TOKEN"]; const response = await fetch(LATEST_RELEASE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { @@ -342,25 +359,32 @@ export const upgradeNoticeHook = ( readonly workingDirectory?: string; readonly isValueTakingFlagToken: (token: string) => boolean; }, - fetchLatestTag: () => Promise = fetchLatestReleaseTag, + fetchLatestTag: ( + env: Readonly>, + ) => Promise = fetchLatestReleaseTag, ): Effect.Effect => Effect.gen(function* () { const context = yield* Effect.context(); + const shell = yield* readShellEnvironment({ names: UPGRADE_NOTICE_ENV_NAMES }).pipe( + Effect.map((environment) => Object.fromEntries(environment.entries())), + Effect.orElseSucceed((): Record => ({})), + ); + const supabaseHome = yield* readSupabaseHome({ join }, homedir()); const outcome = yield* Effect.promise(() => runUpgradeNotice({ - env: process.env, + env: shell, args, cleanShowHelp: info.cleanShowHelp, isValueTakingFlagToken: info.isValueTakingFlagToken, cwd: process.cwd(), resolvedCwd: info.workingDirectory, currentVersion: CLI_VERSION, - supabaseHome: resolveSupabaseHome({ join }, process.env, homedir()), + supabaseHome, now: Date.now, // Runs under the check span's context so the fetch is its child span. fetchLatestTag: () => Effect.runPromiseWith(context)( - Effect.tryPromise({ try: fetchLatestTag, catch: (error) => error }).pipe( + Effect.tryPromise({ try: () => fetchLatestTag(shell), catch: (error) => error }).pipe( Effect.withSpan("UpgradeNotice.fetch"), ), ), diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 089f40101a..dd9bee3d4b 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; import { describe, expect, it } from "vitest"; -import { Effect, Option, Tracer } from "effect"; +import { ConfigProvider, Effect, Option, Tracer } from "effect"; import { type UpgradeNoticeDeps, @@ -478,6 +478,15 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); + it("a project dotenv with a byte-order mark still opts out", async () => { + const ctx = setup({}); + writeFileSync(join(workdir, "supabase", ".env"), "\uFEFFSUPABASE_NO_UPDATE_NOTIFIER=1\n"); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toBe(""); + expect(ctx.fetchCalls).toBe(0); + ctx.cleanup(); + }); + it("--help ignores the project dotenv opt-out — built-ins never load config", async () => { const ctx = setup({ args: ["--help"] }); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); @@ -681,6 +690,51 @@ describe("upgradeNoticeHook", () => { return { stderr: stripVTControlCharacters(written.join("")), span }; } + async function runHookWithProvider(shell: Record) { + const workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-provider-")); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); + const fetchedWith: Array>> = []; + const realWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await Effect.runPromise( + upgradeNoticeHook( + ["db", "branch", "list"], + { + cleanShowHelp: false, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }, + (env) => { + fetchedWith.push(env); + return Promise.resolve("v99.99.99"); + }, + ).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(shell)))), + ); + } finally { + process.stderr.write = realWrite; + rmSync(workdir, { recursive: true, force: true }); + } + return fetchedWith; + } + + it("passes the provider's GITHUB_TOKEN to the release fetch", async () => { + const fetchedWith = await runHookWithProvider({ + SUPABASE_NO_UPDATE_NOTIFIER: "0", + GITHUB_TOKEN: "provider-token", + }); + expect(fetchedWith.map((env) => env["GITHUB_TOKEN"])).toEqual(["provider-token"]); + }); + + it("skips the fetch when the provider sets SUPABASE_NO_UPDATE_NOTIFIER", async () => { + const fetchedWith = await runHookWithProvider({ + SUPABASE_NO_UPDATE_NOTIFIER: "1", + GITHUB_TOKEN: "provider-token", + }); + expect(fetchedWith).toEqual([]); + }); + it("prints the notice for a natively handled command", async () => { expect((await runHook({})).stderr).toContain( "A new version of Supabase CLI is available: v99.99.99", diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index 9e73fbeeae..d301665c97 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -70,8 +70,8 @@ class WorkdirProjectMissingError extends Data.TaggedError("WorkdirProjectMissing * Fails when an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project; a no-op for a defaulted * workdir. * - * Only valid for callers that don't pass `tomlOnly: true` to `loadCliConfig` — that mode can - * return `null` for a `config.json`-only project even though this probe succeeds. + * Valid for any caller that loads config through the resolved config, which probes `config.json` and + * `config.toml` alike. */ export const requireExplicitWorkdirProject = Effect.fnUntraced(function* (cliSettings: { readonly workdir: string; diff --git a/apps/cli/src/command-internal/workdir-search.ts b/apps/cli/src/command-internal/workdir-search.ts deleted file mode 100644 index 70619008fe..0000000000 --- a/apps/cli/src/command-internal/workdir-search.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Whether a config load should climb ancestor directories for `supabase/config.{toml,json}` - * beyond `CommandSettings.workdir`. - * - * An explicit workdir is authoritative and never climbs, so an unrelated ancestor project's - * config can't win. A defaulted workdir still climbs, since its own default resolution only - * probes `config.toml`. - */ -export function shouldSearchAncestors(cliSettings: { readonly explicitWorkdir: boolean }): boolean { - return !cliSettings.explicitWorkdir; -} diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index b3689bd4b1..e164ec913d 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -25,12 +25,12 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 6fe56e7b23..9592f72391 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -25,12 +25,12 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 5099d23869..f600558a2a 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -8,18 +8,18 @@ command — see Notes). ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | -| `/.env.example` | dotenv | optional; merged into the generated `.env` | -| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`loadProjectEnv`), before config.toml validation and again inside `checkDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | -| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | -| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | -| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `resolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | -| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | -| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | -| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | -| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | +| Path | Format | When | +| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | +| `/.env.example` | dotenv | optional; merged into the generated `.env` | +| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`loadProjectEnvValues`), before config.toml validation and again inside `checkDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | +| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | +| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | +| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `resolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | +| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | +| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | +| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | +| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | ## Files Written @@ -61,15 +61,15 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_STACK` | blank/`scratch` path only; when `1`, persists `[experimental] stack = true` and omits Docker-era default ports. Empty is unset; other non-empty values fail closed | no | +| Variable | Purpose | Required? | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | +| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_EXPERIMENTAL_STACK` | blank/`scratch` path only; when `1`, persists `[experimental] stack = true` and omits Docker-era default ports. Empty is unset; non-boolean values fail closed | no | ## Exit Codes diff --git a/apps/cli/src/commands/bootstrap/bootstrap.command.ts b/apps/cli/src/commands/bootstrap/bootstrap.command.ts index 386e2189af..69bd8236e3 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.command.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.command.ts @@ -1,7 +1,10 @@ import { Layer } from "effect"; -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -13,11 +16,11 @@ const config = { Argument.withDescription("Name of the starter template to bootstrap from."), Argument.optional, ), - password: Flag.String("password").pipe( - Flag.withDescription("Password to your remote Postgres database."), - Flag.withAlias("p"), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: "Password to your remote Postgres database.", + }), } as const; export type BootstrapFlags = CliCommand.Command.Config.Infer; @@ -30,4 +33,6 @@ export const bootstrapCommand = Command.make("bootstrap", config).pipe( bootstrap(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(bootstrapRuntimeLayer.pipe(Layer.provideMerge(cliConfigProviderLayer))), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 403fb02bd3..efa8dbb0c4 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -1,7 +1,9 @@ -import { Effect, FileSystem, Option, Path, Redacted, Schedule } from "effect"; +import { Effect, FileSystem, Option, Path, Schedule } from "effect"; import * as HttpClientError from "effect/http/HttpClientError"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; @@ -26,17 +28,15 @@ import { getProjectApiKeys } from "../../command-internal/get-api-keys.ts"; import { sanitizeErrorBody } from "../../command-internal/http-errors.ts"; import type { ConnectSuggestionContext } from "../../command-internal/connect-errors.ts"; import { resolveLinkedConn } from "../../command-internal/db-config.layer.ts"; -import { checkDbToml, loadProjectEnv } from "../../command-internal/db-config.toml-read.ts"; +import { checkDbToml, loadProjectEnvValues } from "../../command-internal/db-config.toml-read.ts"; import { dbPushCore } from "../../command-internal/db-push-core.ts"; +import { resolveDbSeedInput } from "../../command-internal/seed-remote-consent.ts"; import { linkServicesCore } from "../../command-internal/link-services-core.ts"; import { projectCreateCore } from "../../command-internal/project-create-core.ts"; -import { tempPaths } from "../../command-internal/temp-paths.ts"; +import { tempPaths } from "../../shared/config/temp-paths.ts"; import { extractServiceKeys } from "../../command-internal/tenant-keys.ts"; -import { parseDotEnv } from "../../command-internal/dotenv.ts"; -import { - experimentalFeatureEnv, - resolveExperimentalFeature, -} from "../../command-internal/experimental-feature.ts"; +import { parseDotEnv } from "../../shared/config/dotenv.ts"; +import { resolveExperimentalFeature } from "../../command-internal/experimental-feature.ts"; import { initProject } from "../../shared/init/project-init.ts"; import { buildDotEnv, marshalDotEnv } from "./bootstrap.dotenv.ts"; import { @@ -80,6 +80,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const workdirFlag = yield* WorkdirFlag; const dnsResolver = yield* DnsResolverFlag; const yesFlag = yield* resolveYes; + const configValues = yield* CliConfigValues; const isText = output.format === "text"; const retry = { schedule: retrySchedule, times: BOOTSTRAP_MAX_RETRIES } as const; @@ -136,7 +137,6 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ? yield* resolveExperimentalFeature({ feature: "stack", configValue: Effect.succeed(false), - env: yield* experimentalFeatureEnv("stack"), }) : false; @@ -171,27 +171,30 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( if (starter.url.length > 0) { if (isText) yield* output.raw(`Downloading: ${starter.url}\n`, "stdout"); - yield* templateService.download(starter.url, workdir); + yield* configValues.writeThrough(templateService.download(starter.url, workdir)); } else { - yield* initProject({ - cwd: workdir, - force: true, - interactive: false, - yes: yesFlag, - useOrioledb: false, - withVscodeSettings: false, - withIntellijSettings: false, - experimentalStack, - }).pipe(Effect.withSpan("bootstrap.initProject")); + yield* configValues.writeThrough( + initProject({ + cwd: workdir, + force: true, + interactive: false, + yes: yesFlag, + useOrioledb: false, + withVscodeSettings: false, + withIntellijSettings: false, + experimentalStack, + }).pipe(Effect.withSpan("bootstrap.initProject")), + ); } yield* ensureLogin({ openBrowser: tty.stdinIsTty }); - const seededPassword = Option.isSome(flags.password) - ? flags.password.value - : Option.isSome(cliSettings.dbPassword) - ? Redacted.value(cliSettings.dbPassword.value) - : ""; + const seededPassword = Option.getOrElse( + (yield* (yield* configValues.load({ workdir, projectRef: Option.none() })).get( + CliConfigKeys.linkedDb.password, + )).value, + () => "", + ); const created = yield* projectCreateCore({ name: path.basename(workdir), orgId: "", @@ -220,7 +223,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( // Config load must run before link/health/`.env` steps: a malformed config.toml aborts here // rather than after side effects start. const { pushYes, toml } = yield* Effect.gen(function* () { - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); return { pushYes: yield* resolveYesWithProjectEnv(projectEnv), toml: yield* checkDbToml(fs, path, workdir, projectRef), @@ -230,15 +233,19 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } - yield* linkServicesCore({ - ref: projectRef, - serviceKey: anon, - skipPooler: false, - workdir, - }); - const paths = tempPaths(path, workdir); - yield* fs.makeDirectory(path.dirname(paths.projectRef), { recursive: true }); - yield* fs.writeFileString(paths.projectRef, projectRef); + yield* configValues.writeThrough( + Effect.gen(function* () { + yield* linkServicesCore({ + ref: projectRef, + serviceKey: anon, + skipPooler: false, + workdir, + }); + const paths = tempPaths(path, workdir); + yield* fs.makeDirectory(path.dirname(paths.projectRef), { recursive: true }); + yield* fs.writeFileString(paths.projectRef, projectRef); + }), + ); const healthNotify = bootstrapRetryNotify(); yield* Effect.gen(function* () { @@ -266,33 +273,35 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const supabaseUrl = `https://${projectRef}.${cliSettings.projectHost}`; const envFilePath = path.join(workdir, ".env"); let envFileWritten = true; - yield* Effect.gen(function* () { - const examplePath = path.join(workdir, ".env.example"); - const hasExample = yield* fs.exists(examplePath); - let example: Record | undefined; - if (hasExample) { - const content = yield* fs.readFileString(examplePath); - example = yield* Effect.try({ - try: () => parseDotEnv(content), - catch: (cause) => - new BootstrapDotEnvParseError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - } - const env = buildDotEnv(keys, dbConfig, supabaseUrl, example); - yield* fs.writeFileString(envFilePath, marshalDotEnv(env)); - }).pipe( - Effect.catch((cause) => - Effect.gen(function* () { - envFileWritten = false; - yield* output.raw( - `Failed to create .env file: ${cause instanceof Error ? cause.message : String(cause)}\n`, - "stderr", - ); - }), + yield* configValues.writeThrough( + Effect.gen(function* () { + const examplePath = path.join(workdir, ".env.example"); + const hasExample = yield* fs.exists(examplePath); + let example: Record | undefined; + if (hasExample) { + const content = yield* fs.readFileString(examplePath); + example = yield* Effect.try({ + try: () => parseDotEnv(content), + catch: (cause) => + new BootstrapDotEnvParseError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + } + const env = buildDotEnv(keys, dbConfig, supabaseUrl, example); + yield* fs.writeFileString(envFilePath, marshalDotEnv(env)); + }).pipe( + Effect.catch((cause) => + Effect.gen(function* () { + envFileWritten = false; + yield* output.raw( + `Failed to create .env file: ${cause instanceof Error ? cause.message : String(cause)}\n`, + "stderr", + ); + }), + ), + Effect.withSpan("bootstrap.writeDotEnv"), ), - Effect.withSpan("bootstrap.writeDotEnv"), ); // `resolveLinkedConn` doesn't attach a suggestionContext like the full resolver does; build @@ -320,6 +329,10 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( // command, since its CommandSettings-based resolvers would be stale after this handler's // own chdir above. const pushNotify = bootstrapRetryNotify(); + const seed = yield* resolveDbSeedInput( + yield* configValues.load({ workdir, projectRef: Option.some(projectRef) }), + { workdir, ref: projectRef }, + ); yield* dbPushCore({ workdir, projectRef, @@ -330,6 +343,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( includeAll: false, includeRoles: true, includeSeed: true, + seed, includeVault: true, dnsResolver, toml, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index c81ce276c9..a5bf5f2342 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -9,7 +9,6 @@ import { Layer, Option, Path, - Redacted, Schedule, } from "effect"; @@ -43,6 +42,9 @@ import { YesFlag, OutputFlag, } from "../../command-internal/global-flags.ts"; +import { flagInput } from "../../../tests/helpers/config-values-layer.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; @@ -102,8 +104,8 @@ interface SetupOpts { readonly promptTextResponses?: ReadonlyArray; readonly promptConfirmResponses?: ReadonlyArray; readonly promptPasswordResponses?: ReadonlyArray; - /** Seeds `CommandSettings.dbPassword`, the captured `SUPABASE_DB_PASSWORD`. */ - readonly dbPassword?: string; + /** The `--password` flag value the command binds; `null` for no flag, default `"s3cret"`. */ + readonly password?: string | null; /** Raw `SUPABASE_WORKDIR` the settings captured; used verbatim, so no prompt fires. */ readonly workdirEnvValue?: string; readonly env?: Readonly>; @@ -181,9 +183,26 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { workdirEnvValue: opts.workdirEnvValue, projectHost: "supabase.co", accessToken: opts.loggedIn === false ? Option.none() : undefined, - dbPassword: - opts.dbPassword === undefined ? undefined : Option.some(Redacted.make(opts.dbPassword)), }); + const password = opts.password === undefined ? "s3cret" : opts.password; + const configProvider = ConfigProvider.layer( + ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), + ); + const configValues = cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + configProvider, + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs( + password === null ? [] : [flagInput("linkedDb.password", "password", password)], + ), + ), + ), + ), + ); const samples = opts.samples ?? []; const downloads: Array<{ url: string; targetDir: string }> = []; @@ -249,9 +268,8 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), debugLoggerLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, opts.debug ?? false))), - ConfigProvider.layer( - ConfigProvider.fromEnvRecord(opts.env ?? {}, { preserveEmptyStrings: true }), - ), + configProvider, + configValues, ); return { @@ -336,7 +354,7 @@ describe("bootstrap integration", () => { if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ExperimentalFeatureFlagError"); expect(Cause.pretty(exit.cause)).toContain( - "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', ); } expect(yield* fs.exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(false); @@ -364,7 +382,7 @@ describe("bootstrap integration", () => { const path = yield* Path.Path; const s = setup(path, { samples: [NEXTJS_TEMPLATE], - env: { SUPABASE_EXPERIMENTAL_STACK: "yes" }, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, }); yield* bootstrap(flags({ template: Option.some("NextJS") }), FAST_BACKOFF).pipe( Effect.provide(s.layer), @@ -634,7 +652,7 @@ describe("bootstrap integration", () => { it.live("pushes with the flag-sourced password (used as the create password too)", () => Effect.gen(function* () { const path = yield* Path.Path; - const s = setup(path); + const s = setup(path, { password: "pw123" }); yield* bootstrap( flags({ template: Option.some("scratch"), password: Option.some("pw123") }), FAST_BACKOFF, @@ -649,7 +667,7 @@ describe("bootstrap integration", () => { // An explicit `--password ""` (e.g. unset `$SUPABASE_DB_PASSWORD` expanded by the shell) // leaves the password empty, so the create step prompts, and the push reuses that same // resolved connection. - const s = setup(path, { promptPasswordResponses: ["prompted-pw"] }); + const s = setup(path, { promptPasswordResponses: ["prompted-pw"], password: "" }); yield* withEnvVar( "SUPABASE_DB_PASSWORD", undefined, @@ -662,12 +680,10 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("pushes with the settings-captured SUPABASE_DB_PASSWORD password", () => + it.live("pushes with the SUPABASE_DB_PASSWORD password when no flag is given", () => Effect.gen(function* () { const path = yield* Path.Path; - // The create seed reads the captured password from settings, and the push reuses the - // created project's password — no live env read on this path. - const s = setup(path, { dbPassword: "env-pw" }); + const s = setup(path, { password: null, env: { SUPABASE_DB_PASSWORD: "env-pw" } }); yield* bootstrap( flags({ template: Option.some("scratch"), password: Option.none() }), FAST_BACKOFF, @@ -676,10 +692,10 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("seeds the project create request with the settings-captured password", () => + it.live("seeds the project create request with the SUPABASE_DB_PASSWORD password", () => Effect.gen(function* () { const path = yield* Path.Path; - const s = setup(path, { dbPassword: "settings-pw" }); + const s = setup(path, { password: null, env: { SUPABASE_DB_PASSWORD: "settings-pw" } }); yield* bootstrap( flags({ template: Option.some("scratch"), password: Option.none() }), FAST_BACKOFF, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts index 27a69c0d85..f05ab4e48b 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -25,6 +25,7 @@ import { useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../../config/cli-config-flags.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { DebugFlag, @@ -59,6 +60,7 @@ function ambientStubs() { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), ); // These stubs exist only so the Effect type system sees CommandPlatformApi, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 9840f9ef01..e6c90d1f07 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -38,6 +38,9 @@ import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { linkedProjectCacheLayer } from "../../telemetry/linked-project-cache.layer.ts"; +import { flagInput } from "../../../tests/helpers/config-values-layer.ts"; +import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { TemplateService } from "./bootstrap.templates.ts"; import { bootstrap } from "./bootstrap.handler.ts"; import type { BootstrapFlags } from "./bootstrap.command.ts"; @@ -220,6 +223,18 @@ describe("bootstrap linked-project cache location", () => { flagsLayer, debugLogger, successTrailerLayer, + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + Layer.succeed( + CliConfigFlagInputs, + makeCliConfigFlagInputs([flagInput("linkedDb.password", "password", "s3cret")]), + ), + ), + ), + ), ); const flags: BootstrapFlags = { diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index 523475bd6b..7c3df249a0 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -47,7 +47,7 @@ | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_PROJECT_ID` | PARENT project ref fallback (1st candidate) when `--project-ref` is unset (CLI-2167 follow-up) | no (also reads `linked-project.json` → `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes diff --git a/apps/cli/src/commands/config/config.format.ts b/apps/cli/src/commands/config/config.format.ts index c3a48143b8..29707c86cc 100644 --- a/apps/cli/src/commands/config/config.format.ts +++ b/apps/cli/src/commands/config/config.format.ts @@ -1,6 +1,7 @@ import type { ConfigChange } from "@supabase/config"; import { projectConfigApiBlockKeys } from "@supabase/config/internal"; +import type { CliConfigKeyOrigin } from "../../config/cli-config-key.ts"; import { BRANCH_UUID_PATTERN } from "../../command-internal/ref-patterns.ts"; import { sanitizeInlineName } from "../../command-internal/http-errors.ts"; @@ -101,6 +102,55 @@ export function configPlural(count: number, singular: string, pluralForm: string return `${count} ${count === 1 ? singular : pluralForm}`; } +/** The environment variable that supplied a declared value in place of the config file's. */ +export interface ConfigEnvOrigin { + readonly tier: "shell" | "projectEnv"; + readonly envName: string; + /** The `.env*` file, relative to the project root; absent for the shell. */ + readonly file?: string; +} + +export type ConfigEnvOriginLookup = (path: ReadonlyArray) => ConfigEnvOrigin | undefined; + +/** Looks up the env origin of a change path, so a value an env variable overrode is marked as such. */ +export function configEnvOriginLookup( + origins: ReadonlyMap, + relativeFile: (file: string) => string, +): ConfigEnvOriginLookup { + return (path) => { + const origin = origins.get(path.join(".")); + if (origin === undefined || (origin.tier !== "shell" && origin.tier !== "projectEnv")) { + return undefined; + } + return { + tier: origin.tier, + envName: origin.envName, + ...(origin.file === undefined ? {} : { file: relativeFile(origin.file) }), + }; + }; +} + +/** `VAR` or `VAR in supabase/.env.local`, the tail of a row's `(from ...)` note. */ +function configEnvOriginPhrase(origin: ConfigEnvOrigin): string { + const name = sanitizeInlineName(origin.envName); + return origin.file === undefined ? name : `${name} in ${sanitizeInlineName(origin.file)}`; +} + +/** `VAR` or `VAR, supabase/.env.local`, a path's entry in the push summary line. */ +export function configEnvOriginList(origin: ConfigEnvOrigin): string { + const name = sanitizeInlineName(origin.envName); + return origin.file === undefined ? name : `${name}, ${sanitizeInlineName(origin.file)}`; +} + +/** The JSON `origin` of an env-sourced value. */ +export function configEnvOriginPayload(origin: ConfigEnvOrigin): Record { + return { + source: origin.tier === "shell" ? "shell" : "project_env", + env_variable: origin.envName, + ...(origin.file === undefined ? {} : { file: origin.file }), + }; +} + function nullableValueEntry(key: string, value: unknown): Record { return { [key]: value === undefined ? null : value }; } @@ -109,7 +159,11 @@ function nullableValueEntry(key: string, value: unknown): Record { +export function configChangePayloadEntry( + change: ConfigChange, + originFor?: ConfigEnvOriginLookup, +): Record { + const origin = originFor?.(change.path); return { path: change.path, class: change.class, @@ -117,6 +171,7 @@ export function configChangePayloadEntry(change: ConfigChange): Record): string { +export function configRenderChangeLines( + changes: ReadonlyArray, + originFor?: ConfigEnvOriginLookup, +): string { return changes .map((change) => { + const origin = originFor?.(change.path); const env = - change.envVariables === undefined - ? "" - : ` (from env ${sanitizeInlineName(change.envVariables.join(", "))})`; + origin !== undefined + ? ` (from ${configEnvOriginPhrase(origin)})` + : change.envVariables === undefined + ? "" + : ` (from env ${sanitizeInlineName(change.envVariables.join(", "))})`; const block = [ `${configRenderPath(change.path)} [${CONFIG_CLASS_LABELS[change.class]}]`, ` local: ${renderLocalChangeValue(change)}${env}`, diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts index e18db7d386..d0e81a4d72 100644 --- a/apps/cli/src/commands/config/config.load.ts +++ b/apps/cli/src/commands/config/config.load.ts @@ -1,49 +1,101 @@ -import { loadCliConfig } from "../../command-internal/cli-config-load.ts"; -import { Effect } from "effect"; +import { CliConfigParseError } from "@supabase/config/effect"; +import { Effect, Option } from "effect"; +import { isDocumentRecord } from "../../config/cli-config-document.ts"; +import { cliRemoteProjectIdEnvName } from "../../config/cli-config-keys.ts"; +import { selectCliConfigRemote } from "../../config/cli-config-remote.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; +import type { ResolvedCliConfig } from "../../config/cli-config-values.service.ts"; import { missingProjectConfigMessageEffect, relativeConfigPath, } from "../../command-internal/workdir-project.ts"; -import { shouldSearchAncestors } from "../../command-internal/workdir-search.ts"; export { relativeConfigPath }; +interface ConfigWorkdir { + readonly workdir: string; + readonly explicitWorkdir: boolean; +} + +const describeLoadFailure = ( + cliSettings: ConfigWorkdir, + cause: { readonly message: string }, +): string => + cause instanceof CliConfigParseError + ? `failed to parse ${relativeConfigPath(cliSettings.workdir, cause.path)}: ${String(cause.cause)}` + : cause.message; + +/** + * Maps a config load failure onto the command's own load error. A parse failure names the file + * that actually failed, since `config.json` is probed before `config.toml`. An invalid value keeps + * its `CliConfigValueError`, which already names the source that supplied it. + */ +export const mapConfigLoadError = + (cliSettings: ConfigWorkdir, makeError: (message: string) => E) => + ( + effect: Effect.Effect, + ) => + Effect.catchIf( + effect, + (cause): cause is Exclude => + cause._tag !== "CliConfigValueError", + (cause) => Effect.fail(makeError(describeLoadFailure(cliSettings, cause))), + ); + /** - * Loads `supabase/config.{toml,json}` for the `config` family (`diff`, `pull`, `push`) with one - * shared failure shape. A parse failure names the file that actually failed, since `config.json` - * is probed before falling back to `config.toml`. A missing-file message suggests `supabase init` - * only for a defaulted workdir; an explicit `--workdir`/`SUPABASE_WORKDIR` never climbs ancestors - * and fails instead of silently loading an unrelated project's config. + * Loads the resolved config for the `config` family: the `[remotes.*]` block matched to + * `projectRef`, the env overlay and the `env()` values every command resolves. A missing file + * suggests `supabase init` only for a defaulted workdir. */ -export function loadLocalConfig( - cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, - projectRef: string | undefined, +export const loadResolvedConfig = Effect.fnUntraced(function* ( + cliSettings: ConfigWorkdir, + projectRef: Option.Option, makeError: (message: string) => E, ) { - return loadCliConfig(cliSettings.workdir, { - projectRef, - search: shouldSearchAncestors(cliSettings), - }).pipe( - Effect.catchTags({ - CliConfigParseError: (cause) => - Effect.fail( - makeError( - `failed to parse ${relativeConfigPath(cliSettings.workdir, cause.path)}: ${String(cause.cause)}`, - ), - ), - DuplicateRemoteProjectIdError: (cause) => Effect.fail(makeError(cause.message)), - }), - Effect.flatMap((loaded) => - loaded === null - ? Effect.gen(function* () { - const message = yield* missingProjectConfigMessageEffect(cliSettings); - return yield* Effect.fail(makeError(message)); - }) - : Effect.succeed(loaded), - ), - Effect.tap((loaded) => - Effect.annotateCurrentSpan("config.remote_applied", loaded.appliedRemote !== undefined), - ), + const values = yield* CliConfigValues; + const resolvedConfig = yield* values + .load({ workdir: cliSettings.workdir, projectRef, tolerateUnreadableLinkedRef: true }) + .pipe(mapConfigLoadError(cliSettings, makeError)); + if (!resolvedConfig.hasConfigFile) { + return yield* Effect.fail(makeError(yield* missingProjectConfigMessageEffect(cliSettings))); + } + yield* Effect.annotateCurrentSpan( + "config.remote_applied", + Option.isSome(resolvedConfig.appliedRemote), ); -} + return resolvedConfig; +}); + +/** + * The resolved config for the resolved target. A command loads once before it knows the target, to fail + * on a missing or invalid config before any network call; that resolved config stands unless a + * `[remotes.*]` block selects `ref`, since loading again prints its warnings twice. + */ +export const loadTargetResolvedConfig = Effect.fnUntraced(function* ( + cliSettings: ConfigWorkdir, + early: ResolvedCliConfig, + ref: string, + makeError: (message: string) => E, +) { + const remotes = early.loaded.rawDocument?.["remotes"]; + if (!isDocumentRecord(remotes)) return early; + const overrides = yield* early.envValues(Object.keys(remotes).map(cliRemoteProjectIdEnvName)); + const selected = selectCliConfigRemote(remotes, Option.some(ref), (name) => overrides[name]); + return selected === undefined + ? early + : yield* loadResolvedConfig(cliSettings, Option.some(ref), makeError); +}); + +/** What the config file itself declares; `config pull` compares against it because it rewrites the file. */ +export const loadDeclaredFileConfig = Effect.fnUntraced(function* ( + cliSettings: ConfigWorkdir, + projectRef: Option.Option, + makeError: (message: string) => E, +) { + const resolvedConfig = yield* loadResolvedConfig(cliSettings, projectRef, makeError); + const loaded = yield* resolvedConfig.fileDeclared.pipe( + mapConfigLoadError(cliSettings, makeError), + ); + return { resolvedConfig, loaded }; +}); diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index acc1d92185..7661a11c9f 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -8,13 +8,13 @@ writes `config.toml` or any remote configuration.** ## Files Read -| Path | Format | When | -| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (`loadCliConfig` probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | -| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | -| `/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (the config load probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay | +| `/{supabase/,}.env{..local,.local,.,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml` and the `SUPABASE_*` project-env tier of the local operand. `` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | +| `/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -39,13 +39,14 @@ All Bearer-authenticated, all read-only. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | -| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | +| `SUPABASE_` config overrides | override the matching config key (flag > shell > project `.env*` > matched `[remotes.*]` block > base config); the local operand carries the overridden value | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change on an env-resolved property names the variable in the output | no | ## Exit Codes @@ -71,6 +72,7 @@ same as any other non-zero exit. | `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigDiffWorkdirError`) — beats the config read and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigDiffLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | +| `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | | `1` | branch-name `--project-ref` with no linked parent project (`ConfigDiffBranchNotLinkedError`) | | `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`ConfigDiffParentRefInvalidError`) | | `1` | unknown branch (branch-name `--project-ref` 404, `ConfigDiffBranchNotFoundError`) | @@ -90,7 +92,10 @@ blocks are called out). The payload is on **stdout**. One block per difference (` [update|remote-only|local-only]` with `local:`/`remote:` lines; unset renders `(unset)` / `(not returned)`, an undeclared path with a schema default renders ` (schema default — not -declared in config.toml)`, env-resolved values append `(from env VAR, …)`), +declared in config.toml)`, env-resolved values append `(from env VAR, …)`, and a value a +`SUPABASE_*` variable overrode appends `(from SUPABASE_X)` for the shell or +`(from SUPABASE_X in supabase/.env.local)` for a project `.env*` file, path relative to the +workdir), the local operand being exactly what `config push` sends, then a summary count line — `No config differences found.` when clean — followed by a `Note: N block(s) … not returned by the API and … not compared: …` line when the response omitted a block entirely, a `Note: … (masked by the @@ -112,7 +117,8 @@ version of THIS payload contract, currently `1`), `config_schema` (the file's `scope` (`{present, missing}` block lists — the block set is owned by `@supabase/config`), `changes[]` (`path` as a SEGMENT ARRAY — a record key may contain a `.` — plus `class`, `declared`, `local`, `remote`, optional -`env_variables[]`; unset sides are `null`), `masked[]` and `unmanaged[]` +`env_variables[]`, and `origin` — `{source: "shell" | "project_env", env_variable, file?}` — +only on a value a `SUPABASE_*` variable overrode; unset sides are `null`), `masked[]` and `unmanaged[]` (segment-array paths), and `counts` (per class + `total`). ### `-o/--output` (machine formats) diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index b81367f6ec..68910bd7c6 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -2,6 +2,8 @@ import { Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; @@ -64,4 +66,6 @@ export const configDiffCommand = Command.make("diff", config).pipe( ]), Command.withHandler(configDiffHandler), Command.provide(managementApiRuntimeLayer(["config", "diff"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/config/diff/diff.format.ts b/apps/cli/src/commands/config/diff/diff.format.ts index 63b9e54cec..9052d1d224 100644 --- a/apps/cli/src/commands/config/diff/diff.format.ts +++ b/apps/cli/src/commands/config/diff/diff.format.ts @@ -10,6 +10,7 @@ import { configRenderChangeLines, configTargetPhrase, configUnmanagedCaveat, + type ConfigEnvOriginLookup, } from "../config.format.ts"; /** @@ -77,8 +78,12 @@ export function configDiffSummaryMessage( * Human-readable diff body for text mode. Per-change blocks come from `configRenderChangeLines` * (shared with `config push`); this only adds the trailing counts/notes lines. */ -export function renderConfigDiffText(changeSet: ConfigChangeSet, scope: ConfigApiScope): string { - const changeLines = configRenderChangeLines(changeSet.changes); +export function renderConfigDiffText( + changeSet: ConfigChangeSet, + scope: ConfigApiScope, + originFor?: ConfigEnvOriginLookup, +): string { + const changeLines = configRenderChangeLines(changeSet.changes, originFor); const lines: Array = []; const { update, remote_only, local_only, total } = changeSet.counts; @@ -110,6 +115,7 @@ export function configDiffPayload( changeSet: ConfigChangeSet, scope: ConfigApiScope, context: ConfigDiffContext, + originFor?: ConfigEnvOriginLookup, ): Record { return { schema_version: CONFIG_DIFF_PAYLOAD_VERSION, @@ -122,7 +128,7 @@ export function configDiffPayload( context.appliedRemote === undefined ? "base" : `remotes.${context.appliedRemote}`, }, scope: { present: scope.present, missing: scope.missing }, - changes: changeSet.changes.map(configChangePayloadEntry), + changes: changeSet.changes.map((change) => configChangePayloadEntry(change, originFor)), masked: changeSet.masked, unmanaged: changeSet.unmanaged, counts: changeSet.counts, diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index d1be85c361..e09b6bbe88 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -3,7 +3,6 @@ import { diffProjectConfig, fromApiProjectConfig, } from "@supabase/config/effect"; -import { remoteNameForProjectRef } from "@supabase/config/internal"; import { operationDefinitions } from "@supabase/api/effect"; import { Effect, FileSystem, Option } from "effect"; @@ -26,8 +25,12 @@ import { resolveConfigTarget, } from "../../../command-internal/project-target.ts"; import { configIsRecord } from "../config.paths.ts"; -import { loadLocalConfig } from "../config.load.ts"; -import { configApiScope, configScopeLine } from "../config.format.ts"; +import { + loadResolvedConfig, + loadTargetResolvedConfig, + relativeConfigPath, +} from "../config.load.ts"; +import { configApiScope, configEnvOriginLookup, configScopeLine } from "../config.format.ts"; import { configProjectConfigTry } from "../config.project-config.ts"; import { configReadStatusMessage } from "../config.read-status.ts"; import { @@ -80,15 +83,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF // An empty `--project-ref` value is absent, mirroring the resolver's own rule. const requested = Option.filter(flags.projectRef, (value) => value.length > 0); - // Resolved against `cliSettings.workdir`, the same root the project-ref resolver and the - // linked-project cache use, so `--workdir ../other` compares that directory's own config - // against its own linked project. - const loadConfig = (projectRef: string | undefined) => - loadLocalConfig( - cliSettings, - projectRef, - (message) => new ConfigDiffLoadConfigError({ message }), - ); + const makeLoadError = (message: string) => new ConfigDiffLoadConfigError({ message }); // Set once the target ref resolves, so the Effect.ensuring cache write below only fires for // invocations that got that far. @@ -109,10 +104,14 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF Effect.mapError((error) => new ConfigDiffWorkdirError({ message: error.message })), ); - // Loaded before target resolution so a missing config points at `supabase init` rather than - // a not-linked error, and a malformed document doesn't burn a branch-resolution round trip. - // No `[remotes.*]` overlay yet -- it's keyed by the resolved ref, applied below. - let loaded = yield* loadConfig(undefined); + // The config loads before target resolution so a missing file points at `supabase init` + // rather than a not-linked error, and a malformed document doesn't burn a + // branch-resolution round trip. + const earlyResolvedConfig = yield* loadResolvedConfig( + cliSettings, + Option.none(), + makeLoadError, + ); // See resolveConfigTarget's doc comment for the target-resolution rules this preserves. const { ref, branch } = yield* resolveConfigTarget( @@ -126,20 +125,22 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF "config.target_is_branch": branch !== undefined, }); - // Reload only if a `[remotes.*]` entry matches the resolved ref (ADR 0018), matched against - // the raw pre-`env()` `project_id` literal so an `env(REF)` entry that merely resolves to - // `ref` isn't treated as a match -- that would reload the config and duplicate its load-time - // warnings. - const remoteMatchesRef = - remoteNameForProjectRef(loaded.rawDocument?.["remotes"], ref) !== undefined; - if (remoteMatchesRef) { - loaded = yield* loadConfig(ref); - } + // The view `config push` sends: the `[remotes.*]` block matched to `ref` and the env overlay. + const resolvedConfig = yield* loadTargetResolvedConfig( + cliSettings, + earlyResolvedConfig, + ref, + makeLoadError, + ); + const loaded = resolvedConfig.loaded; + const originFor = configEnvOriginLookup(resolvedConfig.origins, (file) => + relativeConfigPath(cliSettings.workdir, file), + ); const context: ConfigDiffContext = { projectRef: ref, branch, - appliedRemote: loaded.appliedRemote, + appliedRemote: Option.getOrUndefined(resolvedConfig.appliedRemote), configSchema: loaded.schemaRef ?? CLI_CONFIG_SCHEMA_URL, }; yield* output.raw(configDiffComparisonLine(context), "stderr"); @@ -201,10 +202,10 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF if (output.format !== "text") { yield* output.success( configDiffSummaryMessage(changeSet, scope), - configDiffPayload(changeSet, scope, context), + configDiffPayload(changeSet, scope, context, originFor), ); } else { - yield* output.raw(renderConfigDiffText(changeSet, scope)); + yield* output.raw(renderConfigDiffText(changeSet, scope, originFor)); } // `--exit-code` sets exit 2 for drift, distinct from the 1 every other failure uses, so a diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index b40aa67f96..4239223b76 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import { vi } from "vitest"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockContextualAnalytics, mockOutput, @@ -21,7 +22,9 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { configDiffHandler } from "./diff.command.ts"; @@ -149,6 +152,7 @@ function setup(opts: SetupOpts = {}) { ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), }), projectFiles, + cliConfigValuesTestLayer, ); return { layer, out, api, telemetry, linkedProjectCache, processControl }; } @@ -334,6 +338,84 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("marks a value a project .env file overrides with the variable and file", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + dotenv: "SUPABASE_API_MAX_ROWS=700\n", + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("local: 700 (from SUPABASE_API_MAX_ROWS in supabase/.env)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("marks a value the shell environment overrides with the variable alone", () => { + const { layer, out } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n' }); + return withEnvVar( + "SUPABASE_API_MAX_ROWS", + "700", + Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("local: 700 (from SUPABASE_API_MAX_ROWS)"); + expect(out.stdoutText).not.toContain("SUPABASE_API_MAX_ROWS in"); + }), + ).pipe(Effect.provide(layer)); + }); + + it.live("carries an origin on an env-sourced change in the machine payload only", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n[auth]\nsite_url = "https://local.example.com"\n', + dotenv: "SUPABASE_API_MAX_ROWS=700\n", + format: "json", + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + const success = out.messages.find((message) => message.type === "success"); + const changes = ((success?.data ?? {}) as Record)[ + "changes" + ] as ReadonlyArray>; + expect(changes.find((change) => (change["path"] as Array)[0] === "api")).toEqual({ + path: ["api", "max_rows"], + class: "update", + declared: true, + local: 700, + remote: 1000, + origin: { + source: "project_env", + env_variable: "SUPABASE_API_MAX_ROWS", + file: "supabase/.env", + }, + }); + expect( + changes.find((change) => (change["path"] as Array)[0] === "auth"), + ).not.toHaveProperty("origin"); + }).pipe(Effect.provide(layer)); + }); + + it.live("an env override beats the matched [remotes.*] value as the local operand", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[api]", + "max_rows = 500", + "[remotes.staging]", + `project_id = "${VALID_REF}"`, + "[remotes.staging.api]", + "max_rows = 1000", + "", + ].join("\n"), + dotenv: "SUPABASE_API_MAX_ROWS=700\n", + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stderrText).toContain( + `Comparing against project ${VALID_REF} using [remotes.staging]`, + ); + expect(out.stdoutText).toContain("local: 700 (from SUPABASE_API_MAX_ROWS in supabase/.env)"); + expect(out.stdoutText).toContain("remote: 1000"); + }).pipe(Effect.provide(layer)); + }); + it.live("declared secrets are masked, not compared, and never count for --exit-code", () => { const { layer, out, processControl } = setup({ toml: [ @@ -583,7 +665,7 @@ describe("config diff integration", () => { if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("ConfigDiffLoadConfigError"); - // loadCliConfig probes both config.toml and config.json, so the message names both. + // The config load probes both config.toml and config.json, so the message names both. expect(causeText).toContain("supabase/config.toml or supabase/config.json: file not found"); expect(causeText).toContain("supabase init"); } @@ -671,7 +753,11 @@ describe("config diff integration", () => { yield* writeProjectFile("config.json", yield* jsonText({ project_id: "test" })); const sub = path.join(tempRoot.current, "nested", "dir"); yield* fs.makeDirectory(sub, { recursive: true }); - const { layer, api } = setup({ workdir: sub, explicitWorkdir: false }); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), sub, path); + const { layer, api } = setup({ + workdir: resolved.workdir, + explicitWorkdir: resolved.explicit, + }); yield* configDiff(noFlags).pipe(Effect.provide(layer)); expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(true); expect(api.requests).not.toHaveLength(0); diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index 91870751f0..2138a8fc49 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -11,15 +11,15 @@ declining any other answer. Never writes on `--dry-run`, on a declined prompt, o ## Files Read -| Path | Format | When | -| ------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (`loadCliConfig` probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); this first load applies NO `[remotes.*]` overlay, regardless of the eventual target. Its own `LoadedCliConfig.rawText` (the exact bytes the loader itself parsed) is taken as this pull's baseline — never re-read separately, so a concurrent edit landing between the parse and a second read can no longer become the accepted baseline out from under the plan | -| `/supabase/config.toml` or `config.json` | TOML/JSON | re-loaded WITH the `[remotes.*]` overlay applied, only when the resolved target ref matches an EXISTING block (block reuse selects it as the destination, `remoteNameForProjectRef`) — a brand-new block has nothing to overlay yet, so this second load is skipped in that case | -| same file, raw on-disk text (TOCTOU re-read) | TOML/JSON | immediately before writing, once the confirmation prompt has been answered — only reached when at least one change is planned and the run is not `--dry-run`; bytes differing from the earlier read abort the write (`ConfigPullFileChangedError`) instead of overwriting a file that changed while the prompt was on screen | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` | -| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | -| `/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| --------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (the config load probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); this first load applies NO `[remotes.*]` overlay, regardless of the eventual target. Its own `LoadedCliConfig.rawText` (the exact bytes the loader itself parsed) is taken as this pull's baseline — never re-read separately, so a concurrent edit landing between the parse and a second read can no longer become the accepted baseline out from under the plan | +| `/supabase/config.toml` or `config.json` | TOML/JSON | re-loaded WITH the `[remotes.*]` overlay applied, only when the resolved target ref matches an EXISTING block (block reuse selects it as the destination, `remoteNameForProjectRef`) — a brand-new block has nothing to overlay yet, so this second load is skipped in that case | +| same file, raw on-disk text (TOCTOU re-read) | TOML/JSON | immediately before writing, once the confirmation prompt has been answered — only reached when at least one change is planned and the run is not `--dry-run`; bytes differing from the earlier read abort the write (`ConfigPullFileChangedError`) instead of overwriting a file that changed while the prompt was on screen | +| `/{supabase/,}.env{..local,.local,.,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml`; a `SUPABASE_*` value in these files is not compared (pull compares the file's own declared values). `` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | +| `/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -61,15 +61,16 @@ locally via the file write above). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json` is read from and written to (`--workdir` flag takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails (with no `supabase init` hint — see the exit-code table) instead of loading a parent project's config | -| `SUPABASE_YES` | answers the confirmation prompt "yes" (same effect as `--yes`); does **not** bypass the uncommitted-changes guard — only `--force` does | no | -| `env(VAR)` references | interpolated into `config.toml` values at load; a change whose LOCAL value resolved from `env()` is always skipped, never overwritten | no | -| `env(VAR)` references | a change whose REMOTE value is itself spelled `env(VAR)` is also always skipped (`remote_env_reference`), never written verbatim — the loader would resolve it against THIS machine's environment on the next load, which would otherwise let a remote value smuggle a local secret into `config diff`/`config push` | no | +| Variable | Purpose | Required? | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json` is read from and written to (`--workdir` flag takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails (with no `supabase init` hint — see the exit-code table) instead of loading a parent project's config | +| `SUPABASE_` config overrides | not applied: pull compares the file's own declared values, with the matched `[remotes.*]` block merged over the base and `env(VAR)` references resolved from the shell and project `.env*` files | no | +| `SUPABASE_YES` | answers the confirmation prompt "yes" (same effect as `--yes`); does **not** bypass the uncommitted-changes guard — only `--force` does | no | +| `env(VAR)` references | interpolated into `config.toml` values at load; a change whose LOCAL value resolved from `env()` is always skipped, never overwritten | no | +| `env(VAR)` references | a change whose REMOTE value is itself spelled `env(VAR)` is also always skipped (`remote_env_reference`), never written verbatim — the loader would resolve it against THIS machine's environment on the next load, which would otherwise let a remote value smuggle a local secret into `config diff`/`config push` | no | ## Exit Codes @@ -83,6 +84,7 @@ exit `0`: neither is a failure. | `1` | the `-o`/`--output` global flag passed (any value — not supported by this command) | | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPullWorkdirError`) — beats the base config load and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigPullLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | +| `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | | `1` | branch-name `--project-ref` with no linked parent project (`ConfigPullBranchNotLinkedError`) | | `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`ConfigPullParentRefInvalidError`) | | `1` | unknown branch (branch-name `--project-ref` 404, `ConfigPullBranchNotFoundError`) | @@ -220,11 +222,11 @@ the -o/--output flag is not supported by config pull; use --output-format json|s `auth.site_url`, `db.settings.*`, `db.pooler.*`) into the config ROOT also changes what `supabase start` uses locally — flagged with a `dual_scope` warning, never refused. - **The written file always reloads (ADR 0023 §d).** Pull never leaves `config.toml`/`.json` in a - state a subsequent `loadCliConfig` call can't parse. Before writing, planned writes are expanded + state a subsequent config load can't parse. Before writing, planned writes are expanded to a fixpoint (a value that GATES other declared fields — e.g. flipping a disabled SMS provider's `enabled` on — pulls its now-required siblings into the SAME write, not just the gate) and the projected result is decoded through the real config schema first, resolving `env(VAR)` references - EXACTLY as the next `loadCliConfig` call will (process env layered with the project's own + as the next config load will (process env layered with the project's own `.env`/`.env.local`, never bare `process.env`); if it still wouldn't load, every write under the offending family/provider table is dropped (`skipped_reason: "would_invalidate"`, plus a `would_invalidate` warning naming the missing field(s) and, when applicable, the exact `env(VAR)` diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index e9791ab60c..afe66499c1 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -2,6 +2,8 @@ import { Layer, Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -79,4 +81,6 @@ export const configPullCommand = Command.make("pull", config).pipe( // `stdinLayer`: the apply confirmation reads piped stdin via `promptYesNo` // on a non-TTY stdin. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "pull"]), stdinLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index a2ff5a0643..4b88013f26 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -23,6 +23,7 @@ import { mockStdin, mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -430,6 +431,7 @@ function setup(opts: SetupOpts = {}) { // BunServices.layer provides (last-wins). gitStatus.layer, projectFiles, + configValuesLayer({ output: outputLayer }), ); return { layer, out, api, telemetry, linkedProjectCache, processControl, gitStatus }; } @@ -1100,6 +1102,69 @@ describe("config pull integration", () => { }, ); + it.live("an env() reference on a numeric key is never replaced by the remote value", () => { + const before = ['project_id = "test"', "[api]", 'max_rows = "env(ROWS)"', ""].join("\n"); + const { layer, out } = setup({ + toml: before, + dotenv: "ROWS=010\n", + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* configPull(noFlags); + expect(yield* readConfig).toContain('max_rows = "env(ROWS)"'); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as Record; + const changes = data["changes"] as ReadonlyArray>; + const rowsChange = changes.find( + (change) => (change["path"] as Array).join(".") === "api.max_rows", + ); + expect(rowsChange).toMatchObject({ written: false, skipped_reason: "env_reference" }); + expect(rowsChange?.["env_variables"]).toEqual(["ROWS"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("an env() reference inside an array is never replaced by the remote list", () => { + const before = [ + 'project_id = "test"', + "[auth]", + 'additional_redirect_urls = ["env(CALLBACK_URL)"]', + "", + ].join("\n"); + const { layer, out } = setup({ + toml: before, + dotenv: "CALLBACK_URL=https://local.example.com/callback\n", + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* configPull(noFlags); + expect(yield* readConfig).toContain('additional_redirect_urls = ["env(CALLBACK_URL)"]'); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as Record; + const changes = data["changes"] as ReadonlyArray>; + const redirectChange = changes.find( + (change) => (change["path"] as Array).join(".") === "auth.additional_redirect_urls", + ); + expect(redirectChange).toMatchObject({ written: false, skipped_reason: "env_reference" }); + expect(redirectChange?.["env_variables"]).toEqual(["CALLBACK_URL"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("compares against the file's declared values, ignoring a SUPABASE_* override", () => { + const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; + const { layer, out } = setup({ toml: before, yes: true }); + return withEnvVar( + "SUPABASE_API_MAX_ROWS", + "1000", + Effect.gen(function* () { + yield* configPull(noFlags); + expect(yield* readConfig).toContain("max_rows = 1000"); + expect(out.stdoutText).toContain("1 change written."); + }), + ).pipe(Effect.provide(layer)); + }); + it.live( "a declared secret is never written, even though the config file has it declared before AND after the run", () => { diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index c84c5d2d82..f7f8853384 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -14,14 +14,14 @@ notes below). A property your file doesn't declare is never written. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME `loadCliConfig` call so a matching `[remotes.]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1). An explicit `--workdir`/`SUPABASE_WORKDIR` is used exactly as given, with no ancestor search, and (CLI-2285) is probed for a project's presence BEFORE target resolution, so a typo'd `--workdir` fails without burning a branch-name/UUID lookup's network round trip | -| `/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` and to collect `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets | -| Auth email template HTML (`content_path`) | HTML | always (CLI-2314 — no longer gated on `auth.enabled`, which controls only the local GoTrue Docker service, not this write); paths resolved per the rules below, CONFINED to the project root (CLI-2320) — a relative `..` escape or an absolute path outside the root aborts before the file is read | -| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | -| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| Path | Format | When | +| --------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always (`config.json` preferred when both exist), AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME resolvedConfig load so a matching `[remotes.]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1). An explicit `--workdir`/`SUPABASE_WORKDIR` is used exactly as given, with no ancestor search, and (CLI-2285) is probed for a project's presence BEFORE target resolution, so a typo'd `--workdir` fails without burning a branch-name/UUID lookup's network round trip | +| `/{supabase/,}.env{..local,.local,.,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml`, the `SUPABASE_*` project-env tier of the pushed config, and `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets. `` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | +| Auth email template HTML (`content_path`) | HTML | always (CLI-2314 — no longer gated on `auth.enabled`, which controls only the local GoTrue Docker service, not this write); paths resolved per the rules below, CONFINED to the project root (CLI-2320) — a relative `..` escape or an absolute path outside the root aborts before the file is read | +| `/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | +| `/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -119,15 +119,16 @@ cannot be resolved from either the read or the file — reported in their own ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | -| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | API profile selection | no | -| `env(VAR)` references | interpolated into `config.toml` values at load | no | -| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | +| Variable | Purpose | Required? | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | +| `SUPABASE_YES` | auto-confirm prompts (`--yes`) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | API profile selection | no | +| `SUPABASE_` config overrides | override the matching config key at the env tier (shell beats project `.env*`, which beats config and a matched `[remotes.*]` block); the pushed config contains the overridden value | no | +| `env(VAR)` references | interpolated into `config.toml` values at load | no | +| `DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_*` | decrypt `encrypted:` (dotenvx) secret values before hashing/pushing; comma-split, first matching key wins | only if a `config.Secret`-typed field (see below) holds an `encrypted:` value — an `encrypted:`-looking string in a non-secret field (e.g. an email template `subject`) never needs a key | ## Exit Codes @@ -139,7 +140,8 @@ cannot be resolved from either the read or the file — reported in their own | `1` | user declined the branch confirmation gate (cancellation, `ConfigPushCancelledError`) — see Output below | | `1` | `--project-ref` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | | `1` | malformed or missing `config.toml`/`config.json` | -| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (see below) | +| `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in `ConfigPushLoadConfigError` | +| `1` | an `encrypted:` (dotenvx) secret anywhere in the document cannot be decrypted (`CliConfigValueError`, see below) | | `1` | invalid `auth.email.*.content_path` (missing/unreadable template file, or a path that resolves outside the project root) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | list-addons failure (network or non-200) | @@ -179,7 +181,12 @@ a machine-mode TTY run or text run with TTY stdin and redirected stdout, decline unless `--yes`/`SUPABASE_YES` is set. A plain-project target never shows this prompt. Then `Comparison scope: (not returned: )` — printed EVERY run, not just when a block is missing (family consistency -with `config diff`/`config pull`). Then, per resource, one of: +with `config diff`/`config pull`). When a `SUPABASE_*` variable supplied any value about to be +pushed, one line follows — printed even with `--yes`, before any prompt: `Pushing N values set +by environment variables: ([, ]), …` (`` is the project `.env*` file, +relative to the workdir, and is omitted for a shell variable; `N` counts only paths the push will +send, so a resource found up to date adds none). Each such row in the update blocks below also +ends `(from )` or `(from in )`. Then, per resource, one of: - `Remote config is up to date.` — no pushable difference existed. - `Remote config has N difference(s) config push cannot write (see notes below).` — @@ -257,6 +264,9 @@ a declined/failed branch gate instead emits this command's standard machine erro envelope (`{_tag: "Error", error: {...}}` in `json` mode, a `{type: "error", ...}` NDJSON event in `stream-json` mode) with no success payload. +`env_sourced` is additive and present only when a `SUPABASE_*` variable supplied a pushed value: +`[{path: [...], origin: {source: "shell" | "project_env", env_variable, file?}}]`. + `json` mode — one flat object (`message` is a one-sentence summary, with a caveat sentence appended for anything withheld — see below); `is_branch`/`branch`/ `parent_project_ref` are additive (CLI-2168/CLI-2289) — `branch`/`parent_project_ref` are @@ -357,7 +367,7 @@ may itself contain a `.`. - **`--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked project** (CLI-2289, the same vocabulary `link`/`config diff` already accept). A value that is exactly 20 lowercase letters is always treated as a ref. A name is resolved against the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID resolves directly and needs no linked project at all. - **Every invocation detects whether the resolved ref is the linked project, one of its branches, or genuinely undeterminable** (CLI-2168) and always echoes which one before doing anything else — see Output above. When `--project-ref` already named a branch by name/UUID, this is known for free (certain, never re-derived from a live probe); otherwise it's a live `GET /v1/projects/{ref}` probe: 200 is a plain project, 404 confirms a branch, and EVERYTHING else (a TIMEOUT, a transport failure, or any other status) degrades to "unknown" — never "project" (that would skip the confirmation gate for what might genuinely be a branch) and never a hard failure (this probe is diagnostic-only and must never block a push that would otherwise succeed). A confirmed branch's own name/parent are recovered best-effort from `.temp/linked-project.json`/`.temp/project-ref` and a branch-list lookup. A CONFIRMED branch target resolved IMPLICITLY (not via an explicit `--project-ref ` this invocation) is gated behind a confirmation before any further network call; a target resolved from an EXPLICIT `--project-ref ` this invocation skips that confirmation (same-invocation intent already expressed once); an "unknown" target is never gated at all. The target-echo line always prints regardless of which shape it is. -- **Resolution runs BEFORE the config load**, not after: a `[remotes.]` overlay is merged INSIDE `loadCliConfig` itself before its one full schema decode, and only one decode may ever run per invocation — reloading with a different `projectRef` a second time would either double the load-time deprecation warnings or wrongly reject a base document that's only valid once its matching remote's overlay applies. The accepted tradeoff: a branch name/UUID resolution's network call can fire even when the local `config.toml` turns out to be malformed (this only affects `--project-ref `; a ref-shaped or absent target never needs a network call to resolve, so a malformed config there still aborts with zero requests made, matching this command's behavior before CLI-2168/CLI-2289). +- **Resolution runs BEFORE the config load**, not after: a `[remotes.]` overlay is merged inside the resolved config load before its one full schema decode, and only one decode may ever run per invocation — reloading with a different `projectRef` a second time would either double the load-time deprecation warnings or wrongly reject a base document that's only valid once its matching remote's overlay applies. The accepted tradeoff: a branch name/UUID resolution's network call can fire even when the local `config.toml` turns out to be malformed (this only affects `--project-ref `; a ref-shaped or absent target never needs a network call to resolve, so a malformed config there still aborts with zero requests made, matching this command's behavior before CLI-2168/CLI-2289). - **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). - The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. @@ -365,7 +375,12 @@ may itself contain a `.`. - **Only properties your file declares, and whose value differs from the project, are written.** Fields the API requires together ship as a group; undeclared members of that group are sent with the project's CURRENT value, read in the same run — so they do not change. Only when the read did not return a member's current value is it sent at the config schema default, and that is always disclosed (a `[group-write]` block in the confirmation output, a `forced` entry in the JSON payload, and a summary `Note:` line) — never applied silently. - **`db.ssl_enforcement`'s presence, not its decoded default, decides the gate.** `@supabase/config`'s projection recovers whether `[db.ssl_enforcement]` (and `storage.image_transformation`/`storage.s3_protocol`) were actually declared, as opposed to decoding to a schema default; an undeclared `[db.ssl_enforcement]` is treated as `disabled` — no read is needed for this any more, since row 1's single response already carries the remote value. - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) follow that same presence rule end to end — declared-but-absent is never confused with explicitly-disabled. -- **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block declares `project_id == `, `@supabase/config` merges that block's subtree over the base config at the raw (pre-decode) level — `mergeRemoteConfig` — so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with a `duplicate project_id for [remotes.] and [remotes.]` message. +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`; matched `[remotes.*]` over base) > default. The pushed + config is the declared document with these overrides applied, so a `SUPABASE_*` variable that + overrides a declared key changes what is pushed. An unparsable `SUPABASE_*` value fails the + command. +- **`[remotes.*]` overrides are merged before push.** When a `[remotes.]` block's effective `project_id` (the `SUPABASE_REMOTES__PROJECT_ID` override when set, else the block's own value) equals ``, the CLI merges that block's subtree over the base config at the raw (pre-decode) level, so only the keys the block declares override the base. `Loading config override: [remotes.]` prints to stderr. Two remotes sharing the target `project_id` abort with a `duplicate project_id for [remotes.] and [remotes.]` message. - **`encrypted:` (dotenvx) secrets are decrypted, digested, and compared before being sent.** `DOTENV_PRIVATE_KEY`(`_*`) values from the shell + `supabase/.env` decrypt the ciphertext; the decrypted plaintext is hashed the same way the platform hashes its own stored value and compared against the digest the effective-configuration read returns for that field. A matching digest is left alone — never resent; a differing, `null`, or absent digest gets the decrypted plaintext sent in the update body. An empty value or an unresolved `env(VAR)` reference is never sent, and is reported by the credential `Note:` line instead. The ciphertext itself is never pushed, and no plaintext or digest value ever appears in CLI output. - **A secret is only compared/sent while its parent container is present and not explicitly disabled** (e.g. a disabled `[auth.hook.custom_access_token]` never sends its `secrets` value, an absent `[auth.captcha]` never sends `security_captcha_secret`) — one uniform gate covering every secret family, in place of the previous version's five hand-coded per-secret gates. - **An undecryptable secret aborts before any network call.** Before the cost-matrix list-addons request or any other service call, every `config.Secret`-typed value in the document is asserted decryptable — not just `auth.*` (the only fields `config push` actually sends), via a document-wide decode hook that runs the same check regardless of which fields a given command reads. This covers `[db.vault]` (a `map[string]Secret`, not just an `auth.*` field). An undecryptable value aborts with a `failed to parse config: ` message, exit code `1`. diff --git a/apps/cli/src/commands/config/push/push.branch-target.ts b/apps/cli/src/commands/config/push/push.branch-target.ts index ee3aee1201..d119a9d9a8 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -9,7 +9,7 @@ import { parseCachedLinkedProject, } from "../../../command-internal/parent-project-ref.ts"; import { BRANCH_PROJECT_REF_PATTERN } from "../../../command-internal/ref-patterns.ts"; -import { readProjectRefFile, tempPaths } from "../../../command-internal/temp-paths.ts"; +import { readProjectRefFile, tempPaths } from "../../../shared/config/temp-paths.ts"; import { Output } from "../../../shared/output/output.service.ts"; /** diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index ee172e7858..9ef706092b 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -1,6 +1,8 @@ import { Layer, Option } from "effect"; import { Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import type * as CliCommand from "effect/cli/Command"; @@ -60,4 +62,6 @@ export const configPushCommand = Command.make("push", config).pipe( ]), Command.withHandler(configPushHandler), Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "push"]), stdinLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index 37cfb4a02e..f7908edd5e 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -2,11 +2,15 @@ import type { ConfigChange } from "@supabase/config"; import { formatNamedRef, sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { + configEnvOriginList, + configEnvOriginPayload, configPlural, configRenderChangeLines, configRenderPath, configRenderValue, type ConfigApiScope, + type ConfigEnvOrigin, + type ConfigEnvOriginLookup, } from "../config.format.ts"; import type { ConfigPushTarget } from "./push.branch-target.ts"; import { comparePaths, pathIn } from "./push.paths.ts"; @@ -47,6 +51,12 @@ export interface PushForced { readonly value: unknown; } +/** A pushed path whose value an environment variable supplied. */ +export interface PushEnvSourced { + readonly path: ReadonlyArray; + readonly origin: ConfigEnvOrigin; +} + const PUSH_UPDATING_PREFIX: Readonly> = { api: "Updating API service with config:", "db.settings": "Updating DB service with config:", @@ -160,6 +170,7 @@ export interface PushUpdatingLineInput { readonly secretsEncoded: ReadonlyArray>; readonly extras: ReadonlyArray; readonly forced: ReadonlyArray; + readonly originFor?: ConfigEnvOriginLookup; } /** @@ -170,13 +181,21 @@ export interface PushUpdatingLineInput { export function pushUpdatingLine(input: PushUpdatingLineInput): string { return ( `${PUSH_UPDATING_PREFIX[input.resource]}\n` + - configRenderChangeLines(input.changes) + + configRenderChangeLines(input.changes, input.originFor) + renderSecretBlocks(input.secrets, input.secretsEncoded) + renderExtraBlocks(input.extras) + renderForcedBlocks(input.forced) ); } +/** The one stderr line naming every value an environment variable supplied, printed even with `--yes`. */ +export function pushEnvSourcedLine(entries: ReadonlyArray): string { + const rendered = entries + .map((entry) => `${configRenderPath(entry.path)} (${configEnvOriginList(entry.origin)})`) + .join(", "); + return `Pushing ${configPlural(entries.length, "value", "values")} set by environment variables: ${rendered}\n`; +} + /** The `Remote config is up to date.` line — no pushable difference existed. */ export function pushUpToDateLine(resource: PushResource): string { return `Remote ${RESOURCE_DISPLAY_NAME[resource]} config is up to date.\n`; @@ -300,6 +319,8 @@ export interface PushPayloadInput { readonly declinedAddons: ReadonlyArray; readonly remoteOnly: number; readonly scope: ConfigApiScope; + /** Pushed paths an environment variable supplied; the payload omits the field when empty. */ + readonly envSourced?: ReadonlyArray; } /** @@ -416,6 +437,14 @@ export function pushPayload(input: PushPayloadInput): Record { declined_addons: input.declinedAddons, remote_only: input.remoteOnly, scope: { present: input.scope.present, missing: input.scope.missing }, + ...(input.envSourced === undefined || input.envSourced.length === 0 + ? {} + : { + env_sourced: input.envSourced.map((entry) => ({ + path: entry.path, + origin: configEnvOriginPayload(entry.origin), + })), + }), }; } diff --git a/apps/cli/src/commands/config/push/push.format.unit.test.ts b/apps/cli/src/commands/config/push/push.format.unit.test.ts index 15c44d4975..274f2f77cb 100644 --- a/apps/cli/src/commands/config/push/push.format.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.format.unit.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "vitest"; import type { PushResource } from "./push.plan.ts"; import type { PushSecretReport } from "./push.secrets.ts"; import { + pushEnvSourcedLine, pushNotes, pushNotPushableLine, pushPayload, @@ -291,6 +292,53 @@ describe("pushUpdatingLine", () => { }); }); +describe("pushEnvSourcedLine", () => { + test("names one shell-supplied path with its variable", () => { + expect( + pushEnvSourcedLine([ + { path: ["api", "max_rows"], origin: { tier: "shell", envName: "SUPABASE_API_MAX_ROWS" } }, + ]), + ).toBe("Pushing 1 value set by environment variables: api.max_rows (SUPABASE_API_MAX_ROWS)\n"); + }); + + test("lists every path, adding the .env file for a project-env value", () => { + expect( + pushEnvSourcedLine([ + { + path: ["auth", "site_url"], + origin: { + tier: "projectEnv", + envName: "SUPABASE_AUTH_SITE_URL", + file: "supabase/.env.local", + }, + }, + { path: ["api", "max_rows"], origin: { tier: "shell", envName: "SUPABASE_API_MAX_ROWS" } }, + ]), + ).toBe( + "Pushing 2 values set by environment variables: " + + "auth.site_url (SUPABASE_AUTH_SITE_URL, supabase/.env.local), " + + "api.max_rows (SUPABASE_API_MAX_ROWS)\n", + ); + }); +}); + +describe("pushUpdatingLine origin notes", () => { + test("annotates only the rows an env origin supplied", () => { + const line = pushUpdatingLine({ + resource: "api", + changes: [API_MAX_ROWS_CHANGE], + secrets: [], + ...NO_EXTRAS_OR_FORCED, + originFor: () => ({ + tier: "projectEnv", + envName: "SUPABASE_API_MAX_ROWS", + file: "supabase/.env", + }), + }); + expect(line).toContain(" local: 2000 (from SUPABASE_API_MAX_ROWS in supabase/.env)\n"); + }); +}); + describe("pushUpToDateLine", () => { test.each([ ["api", "Remote API config is up to date.\n"], @@ -830,6 +878,30 @@ describe("pushPayload", () => { scope: { present: ["api", "auth", "database", "pooler", "realtime", "storage"], missing: [] }, }; + test("adds env_sourced only when an environment variable supplied a pushed value", () => { + expect(pushPayload(BASE_INPUT)).not.toHaveProperty("env_sourced"); + expect( + pushPayload({ + ...BASE_INPUT, + envSourced: [ + { + path: ["api", "max_rows"], + origin: { tier: "projectEnv", envName: "SUPABASE_API_MAX_ROWS", file: "supabase/.env" }, + }, + ], + }), + ).toHaveProperty("env_sourced", [ + { + path: ["api", "max_rows"], + origin: { + source: "project_env", + env_variable: "SUPABASE_API_MAX_ROWS", + file: "supabase/.env", + }, + }, + ]); + }); + test("shapes the full payload, bucketing secrets across all six statuses", () => { expect(pushPayload(BASE_INPUT)).toEqual({ schema_version: 1, diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 3ff2eee34d..37bec45bda 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -1,9 +1,11 @@ import { fromApiProjectConfig, fromConfigDocument } from "@supabase/config"; -import { diffProjectConfig, findCliProjectRoot, type ConfigChange } from "@supabase/config/effect"; +import { diffProjectConfig, type ConfigChange } from "@supabase/config/effect"; import { operationDefinitions } from "@supabase/api/effect"; -import { DateTime, Effect, FileSystem, Option, Path } from "effect"; +import { DateTime, Effect, FileSystem, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { envReferenceNames } from "../../../config/cli-config-document.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -12,12 +14,7 @@ import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; -import { - assertDecryptableSecrets, - envRefNames, - loadProjectEnv, - resolveShellEnv, -} from "../../../command-internal/db-config.toml-read.ts"; +import { assertDecryptableSecrets } from "../../../command-internal/db-config.toml-read.ts"; import { resolveLinkedParentRef } from "../../../command-internal/parent-project-ref.ts"; import { BRANCH_UUID_PATTERN } from "../../../command-internal/ref-patterns.ts"; import { @@ -30,13 +27,20 @@ import { configTargetErrorsFor, resolveConfigTarget, } from "../../../command-internal/project-target.ts"; -import { requireExplicitWorkdirProject } from "../../../command-internal/workdir-project.ts"; -import { shouldSearchAncestors } from "../../../command-internal/workdir-search.ts"; +import { + missingProjectConfigMessageEffect, + relativeConfigPath, + requireExplicitWorkdirProject, +} from "../../../command-internal/workdir-project.ts"; import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; -import { collectDotenvPrivateKeys } from "../../../command-internal/vault-decrypt.ts"; -import { configApiScope, configScopeLine } from "../config.format.ts"; -import { loadLocalConfig } from "../config.load.ts"; +import { mapConfigLoadError } from "../config.load.ts"; +import { + configApiScope, + configEnvOriginLookup, + configScopeLine, + type ConfigEnvOriginLookup, +} from "../config.format.ts"; import { configProjectConfigTry } from "../config.project-config.ts"; import { configReadStatusMessage } from "../config.read-status.ts"; import { loadAuthEmailContent } from "./push.auth-email-content.ts"; @@ -83,12 +87,14 @@ import { configPushBranchPromptLabel, configPushPayloadFields, configPushTargetLines, + pushEnvSourcedLine, pushNotes, pushNotPushableLine, pushPayload, pushSummaryMessage, pushUpdatingLine, pushUpToDateLine, + type PushEnvSourced, type PushForced, type PushUnencodable, } from "./push.format.ts"; @@ -128,17 +134,63 @@ function pushSentSecretPaths(encoded: PushEncoded): ReadonlyArray>, + originFor: ConfigEnvOriginLookup, +): ReadonlyArray { + const seen = new Set(); + return paths.flatMap((path): ReadonlyArray => { + const origin = originFor(path); + const key = path.join("."); + if (origin === undefined || seen.has(key)) return []; + seen.add(key); + return [{ path, origin }]; + }); +} + /** `push.format.ts` must never see a secret's plaintext. */ function toSecretReport(decision: PushSecretDecision) { const { plaintext: _plaintext, ...report } = decision; return report; } -/** `assertDecryptableSecrets` takes a synchronous lookup, so every referenced name resolves up front. */ -const resolveShellEnvRefs = (nodes: ReadonlyArray) => - resolveShellEnv(nodes.flatMap(envRefNames)).pipe( - Effect.mapError((error) => new ConfigPushLoadConfigError({ message: error.message })), +/** Loads the resolved config once per push, so the load-time deprecation warnings print once. */ +const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( + cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, + ref: string, +) { + const configValues = yield* CliConfigValues; + const resolvedConfig = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.some(ref), + tolerateUnreadableLinkedRef: true, + }) + .pipe(mapConfigLoadError(cliSettings, (message) => new ConfigPushLoadConfigError({ message }))); + if (!resolvedConfig.hasConfigFile) { + return yield* new ConfigPushLoadConfigError({ + message: yield* missingProjectConfigMessageEffect(cliSettings), + }); + } + const loaded = resolvedConfig.loaded; + yield* Effect.annotateCurrentSpan("config.remote_applied", loaded.appliedRemote !== undefined); + const projectYes = resolvedConfig.projectEnvValues["SUPABASE_YES"]; + const projectEnv: Record = + projectYes === undefined ? {} : { SUPABASE_YES: projectYes }; + const referenced = yield* resolvedConfig.envValues( + envReferenceNames(loaded.document, loaded.removedDeprecatedExternalProviders), ); + return { + loaded, + lookup: (name: string) => referenced[name], + dotenvPrivateKeys: resolvedConfig.dotenvPrivateKeys, + projectEnv, + originFor: configEnvOriginLookup(resolvedConfig.origins, (file) => + relativeConfigPath(cliSettings.workdir, file), + ), + }; +}); const mapPushBranchResolveError = mapHttpError({ networkError: ConfigPushBranchResolveNetworkError, @@ -163,7 +215,6 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; // `--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked // project. An empty value is treated as absent, mirroring the resolver's own rule. @@ -175,26 +226,12 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF yield* Effect.gen(function* () { // 0. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a directory before - // anything else touches it: a workdir naming a regular file makes `loadProjectEnv` throw - // ENOTDIR with a confusing "failed to read environment file" error instead of this one. + // anything else touches it: a workdir naming a regular file makes the project env load fail + // with a confusing "failed to read environment file" error instead of this one. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new ConfigPushWorkdirError({ message: error.message })), ); - // `--yes`/`SUPABASE_YES` resolves against the project env (not just the flag + shell env), so - // a `SUPABASE_YES` set only in `supabase/.env` auto-confirms. The project root climbs to find - // it only when `--workdir` was defaulted; an explicit `--workdir ../other` pushes that - // directory's own config.toml without climbing to another root's linked project. - const projectRoot = - (yield* findCliProjectRoot(cliSettings.workdir, { - search: shouldSearchAncestors(cliSettings), - })) ?? cliSettings.workdir; - const projectEnv = yield* loadProjectEnv(fs, path, projectRoot); - const yes = yield* resolveYesWithProjectEnv(projectEnv); - // dotenvx private keys for decrypting `encrypted:` secrets, from the shell + project env; - // `process.env` wins over `supabase/.env`, matching `db-config.toml-read.ts`. - const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnv, ...process.env }); - // 0.5. An explicit `--workdir`/`SUPABASE_WORKDIR` with no project fails here, before a // branch-name/UUID lookup burns a network round trip. A defaulted workdir is untouched: in a // config-less directory with no linked project, it instead fails with step 1's not-linked @@ -207,7 +244,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // resolver still flushes telemetry and, once a ref is known, writes the linked-project cache. // // Runs before the config load below: a `[remotes.]` overlay is merged inside - // `loadCliConfig` before its one schema decode, so a base document that's invalid without its + // the resolved config load before its one schema decode, so a base document that's invalid without its // overlay must never be decoded on its own — this can cost a network round trip before a // malformed `config.toml` is caught. const { ref, branch } = yield* resolveConfigTarget( @@ -222,16 +259,16 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }); // 2. Load config.toml with the resolved ref (a TOML parse error aborts before any network - // call); a matching `[remotes.]` overlay merges before decode in the same call. + // call); the matching `[remotes.]` overlay merges before decode in the same call. // - // Uses `loadLocalConfig` (needs the fully decoded config) rather than the tolerant - // `db-config.toml-read.ts` subtree reader, converting its parse/duplicate-remote/missing-file - // failures into this family's own tagged error. - const loaded = yield* loadLocalConfig( + // Needs the fully decoded document and value origins, which the tolerant + // `db-config.toml-read.ts` subtree reader does not produce. + const { loaded, lookup, dotenvPrivateKeys, projectEnv, originFor } = yield* loadPushConfig( cliSettings, ref, - (message) => new ConfigPushLoadConfigError({ message }), ); + // `SUPABASE_YES` set only in `supabase/.env` auto-confirms, not just the flag and shell env. + const yes = yield* resolveYesWithProjectEnv(projectEnv); // Printed from inside config load, before any command output. if (loaded.appliedRemote !== undefined) { yield* output.raw( @@ -240,14 +277,6 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF ); } const config = loaded.config; - const shellEnv = yield* resolveShellEnvRefs([ - loaded.document, - loaded.removedDeprecatedExternalProviders, - ]); - // Reached only when an `env(VAR)` literal survives `@supabase/config`'s own (narrower) - // interpolation pass unresolved but this wider shell+project-env lookup can still resolve it. - const secretEnvLookup = (name: string): string | undefined => - shellEnv.get(name) ?? projectEnv[name]; // 3. Assert every `encrypted:` value in the document can be decrypted, even fields `config // push` never itself pushes — this must run before the cost matrix or any service is touched. @@ -257,10 +286,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // one of them would skip the check. const secretError = yield* Effect.sync( () => - assertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? + assertDecryptableSecrets(loaded.document, lookup, dotenvPrivateKeys) ?? assertDecryptableSecrets( { auth: { external: loaded.removedDeprecatedExternalProviders } }, - secretEnvLookup, + lookup, dotenvPrivateKeys, ), ).pipe(Effect.withSpan("config.push.verifyDecryptable")); @@ -268,14 +297,11 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF return yield* new ConfigPushLoadConfigError({ message: secretError }); } - // Config lives at /supabase/config.{toml,json}. - const configProjectRoot = path.dirname(path.dirname(loaded.path)); - // 4. Email content validation runs during config load, before any network call, and is // unconditional regardless of `config.auth.enabled` — that flag only toggles the local GoTrue // Docker service and doesn't gate whether `auth` is pushed, so gating this load too would // silently push empty content over a real hosted customization. - const authEmailContent = yield* loadAuthEmailContent(configProjectRoot, config.auth.email); + const authEmailContent = yield* loadAuthEmailContent(cliSettings.workdir, config.auth.email); // 5. Determine the push target and, for a confirmed branch, gate the push behind an explicit // confirmation before any further network call. A target resolved from an explicit @@ -437,6 +463,46 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF storage: pushResourceEnabled("storage", config, local), }; + // Announced before any prompt so a `--yes` run still shows what the environment supplied; + // only paths an encoder will actually send are counted, so an up-to-date resource adds none. + const plannedEncodings: ReadonlyArray> = PUSH_RESOURCES.filter( + (resource) => + resourceEnabled[resource] && !scope.missing.includes(pushResponseBlock(resource)), + ).map((resource) => { + const changes = plan.changesByResource[resource]; + switch (resource) { + case "api": + return encodeApiBody({ changes, local, remote }); + case "db.settings": + return encodeDbSettingsBody({ changes, local, remote }); + case "db.network_restrictions": + return encodeNetworkRestrictionsBody({ changes, local, remote }); + case "db.ssl_enforcement": + return encodeSslEnforcementBody({ changes, local, remote }); + case "auth": + return encodeAuthBody({ + changes, + local, + remote, + secrets, + emailContent: authEmailContent, + remoteAuthAttributes, + now, + }); + case "storage": + return encodeStorageBody({ changes, local, remote, config }); + } + }); + const envSourced = envSourcedPaths( + plannedEncodings + .filter((encoded) => encoded.body !== undefined) + .flatMap((encoded) => pushServiceChanges(encoded, pushSentSecretPaths(encoded))), + originFor, + ); + if (envSourced.length > 0) { + yield* output.raw(pushEnvSourcedLine(envSourced), "stderr"); + } + const services: Array = []; const unsupported: Array> = [...plan.unsupported]; const unencodable: Array = []; @@ -474,6 +540,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF secretsEncoded: encoded.secretsEncoded ?? [], extras: encoded.extras, forced: encoded.forced, + originFor, }), "stderr", ); @@ -736,6 +803,12 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF declinedAddons, remoteOnly: plan.remoteOnly, scope, + envSourced: envSourcedPaths( + services + .filter((service) => service.status === "updated") + .flatMap((service) => service.changes), + originFor, + ), }; yield* output.success(pushSummaryMessage(payloadInput), { ...pushPayload(payloadInput), diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 867f1b51e4..2f6131fb7f 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -17,6 +17,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockAnalytics, mockContextualAnalytics, @@ -40,9 +41,11 @@ import { import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { secretDigestHex } from "./push.secret.ts"; +import { configDiff } from "../diff/diff.handler.ts"; import { configPush } from "./push.handler.ts"; import { configPushHandler } from "./push.command.ts"; @@ -400,6 +403,7 @@ function setup(opts: { Layer.succeed(YesFlag, opts.yes ?? false), workdirFilesLayer(opts.toml, opts.seed), cliConfigProviderLayer, + cliConfigValuesTestLayer, ); return { layer, out, api, telemetry, linkedProjectCache }; } @@ -464,6 +468,7 @@ describe("config push integration", () => { }), mockStdin(true), Layer.succeed(YesFlag, true), + cliConfigValuesTestLayer, ); return Effect.gen(function* () { yield* writeWorkdirFile(["supabase", "config.json"], "{not valid json"); @@ -510,6 +515,204 @@ schemas = ["public", "remote_schema"] }).pipe(Effect.provide(layer)); }); + it.live("pushes the SUPABASE_API_SCHEMAS env override instead of the config.toml value", () => { + const { layer, api } = setup({ + toml: `project_id = "test" +[api] +enabled = true +schemas = ["public"] +`, + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { ...(a["api"] as Record), db_schema: "public" }, + }), + }), + }, + }); + return withEnvVar( + "SUPABASE_API_SCHEMAS", + "public,env_schema", + Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/postgrest"), + ); + expect(update?.body).toMatchObject({ db_schema: "public,env_schema" }); + }), + ).pipe(Effect.provide(layer)); + }); + + const schemasToml = `project_id = "test" +[api] +enabled = true +schemas = ["public"] +`; + const remoteSchemas = { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { ...(a["api"] as Record), db_schema: "public" }, + }), + }), + }; + + it.live("names a value the shell environment supplied in a stderr line, even with --yes", () => { + const { layer, out } = setup({ toml: schemasToml, yes: true, v2: remoteSchemas }); + return withEnvVar( + "SUPABASE_API_SCHEMAS", + "public,env_schema", + Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + "Pushing 1 value set by environment variables: api.schemas (SUPABASE_API_SCHEMAS)\n", + ); + expect(out.stderrText).toContain("(from SUPABASE_API_SCHEMAS)"); + }), + ).pipe(Effect.provide(layer)); + }); + + it.live("names the project .env file that supplied a pushed value", () => { + const { layer, out } = setup({ toml: schemasToml, yes: true, v2: remoteSchemas }); + return Effect.gen(function* () { + yield* writeWorkdirFile(["supabase", ".env"], "SUPABASE_API_SCHEMAS=public,env_schema\n"); + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + "Pushing 1 value set by environment variables: api.schemas (SUPABASE_API_SCHEMAS, supabase/.env)\n", + ); + expect(out.stderrText).toContain("(from SUPABASE_API_SCHEMAS in supabase/.env)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints no environment line when the config file supplies every pushed value", () => { + const { layer, out } = setup({ + toml: schemasToml.replace('["public"]', '["public", "file_schema"]'), + yes: true, + v2: remoteSchemas, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).not.toContain("set by environment variables"); + expect(out.stderrText).not.toContain("(from SUPABASE_"); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints no environment line when the env value already matches the project", () => { + const { layer, out } = setup({ toml: schemasToml, yes: true, v2: remoteSchemas }); + return withEnvVar( + "SUPABASE_API_SCHEMAS", + "public", + Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).not.toContain("set by environment variables"); + }), + ).pipe(Effect.provide(layer)); + }); + + it.live("lists env-sourced paths in the machine payload", () => { + const { layer, out } = setup({ + toml: schemasToml, + yes: true, + format: "json", + v2: remoteSchemas, + }); + return withEnvVar( + "SUPABASE_API_SCHEMAS", + "public,env_schema", + Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const success = out.messages.find((message) => message.type === "success"); + expect(((success?.data ?? {}) as Record)["env_sourced"]).toEqual([ + { + path: ["api", "schemas"], + origin: { source: "shell", env_variable: "SUPABASE_API_SCHEMAS" }, + }, + ]); + }), + ).pipe(Effect.provide(layer)); + }); + + it.live( + "config diff shows the value config push sends when an env value overrides a matched remote", + () => { + const { layer, out, api } = setup({ + toml: `${schemasToml} +[remotes.staging] +project_id = "${REF}" +[remotes.staging.api] +schemas = ["public", "remote_schema"] +`, + yes: true, + format: "json", + v2: remoteSchemas, + }); + return withEnvVar( + "SUPABASE_API_SCHEMAS", + "public,env_schema", + Effect.gen(function* () { + yield* configDiff({ projectRef: Option.none(), exitCode: false }); + yield* configPush({ projectRef: Option.none() }); + + const diff = out.messages.find( + (message) => (message.data as Record | undefined)?.["changes"], + ); + const changes = ((diff?.data ?? {}) as Record)[ + "changes" + ] as ReadonlyArray>; + const diffLocal = changes.find( + (change) => (change["path"] as Array).join(".") === "api.schemas", + )?.["local"]; + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/postgrest"), + ); + expect(diffLocal).toEqual(["public", "env_schema"]); + expect(update?.body).toMatchObject({ db_schema: (diffLocal as Array).join(",") }); + }), + ).pipe(Effect.provide(layer)); + }, + ); + + it.live("selects the [remotes.*] block named by SUPABASE_REMOTES__PROJECT_ID", () => { + const { layer, out, api } = setup({ + toml: `project_id = "test" +[api] +enabled = true +schemas = ["public"] + +[remotes.staging] +project_id = "aaaaaaaaaaaaaaaaaaaa" +[remotes.staging.api] +schemas = ["public", "remote_schema"] +`, + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { ...(a["api"] as Record), db_schema: "public" }, + }), + }), + }, + }); + return withEnvVar( + "SUPABASE_REMOTES_STAGING_PROJECT_ID", + REF, + Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/postgrest"), + ); + expect(update?.body).toMatchObject({ db_schema: "public,remote_schema" }); + }), + ).pipe(Effect.provide(layer)); + }); + it.live("aborts when two [remotes.*] blocks share the target project_id", () => { const { layer, api } = setup({ toml: `project_id = "test"\n[remotes.a]\nproject_id = "${REF}"\n[remotes.b]\nproject_id = "${REF}"\n`, @@ -792,17 +995,21 @@ max_rows = 1000 ).pipe(Effect.provide(layer)); }); - it.live("loads config-push env from the project root when --workdir names a subdirectory", () => + it.live("loads config-push env from the project root when invoked from a subdirectory", () => withEnvVar( "SUPABASE_YES", undefined, Effect.gen(function* () { + const path = yield* Path.Path; const sub = yield* makeWorkdirDirectory("nested", "dir"); + yield* writeWorkdirFile(["supabase", "config.toml"], 'project_id = "test"\n'); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), sub, path); const { layer, api } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, stdinIsTty: false, pipedAnswers: ["n"], - workdir: sub, + workdir: resolved.workdir, + explicitWorkdir: resolved.explicit, }); yield* writeWorkdirFile(["supabase", ".env"], "SUPABASE_YES=true\n"); yield* configPush({ projectRef: Option.none() }).pipe(Effect.provide(layer)); @@ -857,6 +1064,7 @@ max_rows = 1000 }), mockStdin(true), Layer.succeed(YesFlag, true), + cliConfigValuesTestLayer, ); return Effect.gen(function* () { const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); @@ -990,6 +1198,7 @@ max_rows = 1000 }), mockStdin(true), Layer.succeed(YesFlag, true), + cliConfigValuesTestLayer, ); return Effect.gen(function* () { const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); @@ -1444,6 +1653,7 @@ function setupService(opts: { Layer.succeed(YesFlag, opts.yes ?? false), workdirFilesLayer(opts.toml), cliConfigProviderLayer, + cliConfigValuesTestLayer, ); return { layer, out, apiMock }; } @@ -1606,7 +1816,7 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" undefined, Effect.gen(function* () { const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), + Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); @@ -1630,7 +1840,7 @@ openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" undefined, Effect.gen(function* () { const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), + Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); @@ -1656,7 +1866,7 @@ secret = "env(CAPTCHA_SECRET_FROM_ROOT_ENV)" `CAPTCHA_SECRET_FROM_ROOT_ENV="${DOTENVX_ENCRYPTED_VALUE}"\n`, ); const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), + Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); @@ -1696,7 +1906,7 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" undefined, Effect.gen(function* () { const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), + Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); diff --git a/apps/cli/src/commands/config/push/push.secret.ts b/apps/cli/src/commands/config/push/push.secret.ts index 288cb5c787..a2d955b7d9 100644 --- a/apps/cli/src/commands/config/push/push.secret.ts +++ b/apps/cli/src/commands/config/push/push.secret.ts @@ -8,7 +8,7 @@ import { createHmac } from "node:crypto"; -import { decryptSecret } from "../../../command-internal/vault-decrypt.ts"; +import { decryptSecret } from "../../../shared/config/vault-decrypt.ts"; const ENV_PATTERN = /^env\((.*)\)$/; const ENCRYPTED_PREFIX = "encrypted:"; diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 5c6895540f..8964723a50 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -4,6 +4,8 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbAdvisors } from "./advisors.handler.ts"; import { dbAdvisorsRuntimeLayer } from "./advisors.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -65,4 +67,6 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( ), ), Command.provide(dbAdvisorsRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index c1c6af00c5..0ceba091b8 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -29,7 +29,7 @@ it, and JSON `null` disables formatting without disabling safe compaction. | Path | Format | When | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always, `config.json` preferred (db port/password, `[experimental.pgdelta]`, deno_version) | | `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | | `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `readDbToml`/`checkDbToml` `Config.Validate` pipeline shared by every `db`/`migration` subcommand that loads config (`db dump`/`pull`/`reset`/`push`/`schema declarative generate`/`sync`, `migration up`/`down`/`squash` — documented once here rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | @@ -105,13 +105,14 @@ of this command's own target resolve, ahead of the differ container. | Variable | Purpose | Required? | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (linked); ignored for a target other than the linked project (see Notes) | no | | `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | | `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled` (default true; `false` selects migra); a value that is not a boolean fails the command | no | | `PGDELTA_DEBUG` | pg-delta debug capture | no | | `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, passed to each image operation) | no | @@ -120,8 +121,9 @@ of this command's own target resolve, ahead of the differ container. `SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned through the same primitives. -The historical `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in is **not read**: pg-delta is the -default engine, and an explicit `[experimental.pgdelta] enabled = false` rollback is +`SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` has no effect on the pgadmin path: `--use-pgadmin` +always selects the pgadmin engine. The historical `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in is +**not read**: pg-delta is the default engine, and an explicit `enabled = false` rollback is authoritative. `SUPABASE_INTERNAL_IMAGE_REGISTRY` applies to the differ's own image resolution too. The @@ -206,8 +208,19 @@ transaction metadata. ## Notes / Delegation +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`) > default. The whole config is decoded up front, so an + invalid value fails the command. A `--linked` target also applies a matching `[remotes.*]` + block. The linked-database password env is withheld when the target differs from + `.temp/project-ref`, with a `Not sending SUPABASE_DB_PASSWORD to : ...` warning and a + temporary login role instead. `--password` is rejected with `--db-url` or `--local`. +- **pg-delta selection**: `--use-pg-delta` sets `[experimental.pgdelta].enabled` at the flag tier, + so `--use-pg-delta=false` selects migra over env and config. The default is pg-delta when the + stack backend is active or `[experimental.pgdelta].enabled` resolves true. An explicit + `--use-migra` or `--use-pgadmin` (a true value) always wins; `--use-migra=false` keeps pg-delta; the stack backend always uses pg-delta. - `--use-migra`, `--use-pgadmin`, `--use-pg-delta` are a mutually-exclusive engine group - (pg-delta is the default unless `[experimental.pgdelta] enabled = false`); `--db-url` / `--linked` / `--local` are a mutually-exclusive target group (default + (pg-delta is the default unless `[experimental.pgdelta] enabled = false`); `--db-url` / + `--linked` / `--local` are a mutually-exclusive target group (default `--local`). `--use-pg-schema` is removed and rejects before this group is even checked (see Notes below), so it is never a live member of the group. - **`--project-ref`** overrides ONLY the linked-ref resolution `ProjectRefResolver` @@ -223,7 +236,8 @@ transaction metadata. `--linked` was explicitly set either. It still fires for e.g. `--from local --to migrations --project-ref X` (explicit mode, `--linked` unchanged, and neither side `linked`), where the flag would otherwise go silently unused - (stricter than `SUPABASE_PROJECT_ID`, which is simply left unused on a non-linked target). `--use-pgadmin --linked` + (deliberately stricter than `SUPABASE_PROJECT_ID`, which is simply unused + on a non-linked target). `--use-pgadmin --linked` honors the flag like every other native engine (CLI-1968 — same target resolve); `--use-pg-schema` is removed and rejects before any target resolution happens (see Notes below), so this guard never runs for it. @@ -242,7 +256,7 @@ publishes only when the roles file is unchanged, and retention keeps three entri A falsy `SUPABASE_SHADOW_CACHE` bypasses this cache. A failed warm restore recreates the database; a failed cache export warns and continues with the live shadow. -On by default; setting `SUPABASE_SHADOW_CACHE` to anything not boolean-true (`false`/`0`/empty/garbage, +On by default; setting `SUPABASE_SHADOW_CACHE` to anything that does not parse as true (`false`/`0`/empty/garbage, honored from the ambient env AND the project's dotenv, e.g. `supabase/.env`) turns it off, restoring the documented uncached lifecycle. A warm hit skips the platform baseline, so the `Initialising schema...` progress line does not print — diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index b7892cc408..fae503c4f5 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -1,11 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbDiff } from "./diff.handler.ts"; import { dbDiffRuntimeLayer } from "./diff.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { // The engine flags are a mutually-exclusive group, modelled as `Option` so the mutex check keys @@ -25,10 +28,10 @@ const config = { Flag.optional, Flag.withHidden, ), - usePgDelta: Flag.Boolean("use-pg-delta").pipe( - Flag.withDescription("Use pg-delta to generate schema diff."), - Flag.optional, - ), + usePgDelta: CliConfigKeys.experimental.pgdelta.enabled.flag({ + name: "use-pg-delta", + description: "Use pg-delta to generate schema diff.", + }), strictCoverage: Flag.Boolean("strict-coverage").pipe( Flag.withDescription( "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", @@ -125,4 +128,6 @@ export const dbDiffCommand = Command.make("diff", config).pipe( ), ), Command.provide(dbDiffRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 85a3942bc6..cf965d4a84 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -12,6 +12,8 @@ import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { aqua, yellow } from "../../../command-internal/colors.ts"; import { readDbToml, @@ -54,7 +56,6 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { resolveDiffEngine, schemaPathsTransitionWarning, - shouldUsePgDelta, } from "../../../command-internal/diff-engine.ts"; import { formatMigrationTimestamp, @@ -70,11 +71,7 @@ import { } from "../shared/pgdelta-engine.service.ts"; import { LoadPgDeltaSqlFiles } from "../shared/pgdelta-files.ts"; import { writePgDeltaMigrations } from "../shared/pgdelta-migrations.write.ts"; -import { - type PgDeltaContext, - isPgDeltaDebugEnabled, - resolvePgDeltaProjectId, -} from "../../../command-internal/pgdelta.ts"; +import { type PgDeltaContext, isPgDeltaDebugEnabled } from "../../../command-internal/pgdelta.ts"; import { prepareShadowSource } from "../shared/shadow-source.ts"; import type { DbDiffFlags } from "./diff.command.ts"; import { classifyExplicitRef, unknownTargetMessage } from "./diff.explicit.ts"; @@ -127,6 +124,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const dnsResolver = yield* DnsResolverFlag; const debug = yield* DebugFlag; const stackApi = yield* Effect.serviceOption(StackApi); + const configValues = yield* CliConfigValues; // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}). @@ -245,8 +243,12 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "local": { const backend = yield* currentStackBackend; if (backend.kind !== "stack") { + const hostConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(mergedLinkedRef), + }); const connection = { - host: yield* getHostname(), + host: yield* getHostname(hostConfig), port: cfg.port, user: "postgres", password: cfg.password, @@ -327,8 +329,12 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }); const source = yield* resolveRef(from); const desired = yield* resolveRef(to); + const explicitResolvedConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(mergedLinkedRef), + }); const explicitCtx: PgDeltaContext = { - projectId: resolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), + projectId: (yield* explicitResolvedConfig.get(CliConfigKeys.projectId)).value, cwd: cliSettings.workdir, denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, @@ -340,7 +346,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { desired, schema: flags.schema, formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, }); // Explicit-mode output: `--output` file, or stdout with no trailing newline @@ -414,6 +420,10 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (cfg.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${cfg.appliedRemote}]\n`, "stderr"); } + const resolvedConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(linkedRef), + }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* NetworkIdFlag; @@ -432,9 +442,6 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // enabled-for-setup flags) reflects the matching `[remotes.]` override too, same // as `cfg` above (`readDbToml(..., linkedRef)`). connType === "linked" ? linkedRef : undefined, - // `cfg`'s own remote-override-key tracking (same matched block), so a remote-set - // bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` env var. - cfg.remoteOverrideKeys, ); const resolved = yield* resolver.resolve({ @@ -450,25 +457,16 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { - // `SUPABASE_PROJECT_ID` env override wins, then config.toml's `project_id`, then the - // workdir basename fallback; the matched `[remotes.]` block's own `project_id` - // suppresses the raw env argument on the linked path — see `readDbToml`'s doc comment. - projectId: resolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), + projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, cwd: cliSettings.workdir, denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); - // Engine resolution: the pg-delta config/flag gate, read from the - // (possibly remote-merged) config. const onStackBackend = (yield* currentStackBackend).kind === "stack"; - const pgDeltaDefault = - onStackBackend || - shouldUsePgDelta({ - configEnabled: cfg.pgDelta.enabled, - usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), - }); + const pgDeltaSetting = yield* resolvedConfig.get(CliConfigKeys.experimental.pgdelta.enabled); + const pgDeltaDefault = onStackBackend || pgDeltaSetting.value; const useDelta = resolveDiffEngine({ useMigra, usePgAdmin, @@ -634,7 +632,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }, schema: flags.schema, formatOptions, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, }); return { sql: result.sql, files: result.files, hazards: result.hazards }; @@ -714,13 +712,22 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ? "the configured declarative schema directory" : declarativeDir.split("\\").join("/"); ignoredDeclarativeAdvisory = declarativeBaselineAdvisory(isAbsolute ? null : displayPath); + // A per-run `--use-pg-delta` says nothing about what `--use-migra` or `declarative sync` + // would resolve, so fall back to what the config file declares. + const pgDeltaEnabled = + pgDeltaSetting.origin.tier === "flag" + ? yield* resolvedConfig.fileDeclared.pipe( + Effect.map((declared) => declared.config.experimental.pgdelta?.enabled ?? true), + Effect.orElseSucceed(() => pgDeltaSetting.value), + ) + : pgDeltaSetting.value; // Mirrors migra's declarative source precedence (`loadDeclaredSchemas`), which applies // only to local targets: schema_paths first, then this dir while pg-delta stays enabled // in config, then supabase/schemas. The stack backend rejects migra outright. const migraReadsDeclarativeDir = resolved.isLocal && cfg.schemaPathPatterns.length === 0 && - (cfg.pgDelta.enabled || + (pgDeltaEnabled || declarativeDirAbsolute === path.resolve(cliSettings.workdir, "supabase", "schemas")); const suggestMigra = migraReadsDeclarativeDir && !onStackBackend; if (fileRequested) { @@ -728,7 +735,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // contradict, so suggest the always-accepted flag whenever an override was applied. const nextSteps = declarativeNextSteps( suggestMigra, - !cfg.pgDelta.enabled || cfg.appliedRemote !== undefined, + !pgDeltaEnabled || cfg.appliedRemote !== undefined, ); yield* output.raw( writesMigration diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 7d34ced5b5..c54f8a7de3 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -35,6 +35,7 @@ import { sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -79,6 +80,8 @@ import { StackNativeEngineError } from "../../../command-internal/stack-local-da import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; interface SetupOpts { + readonly usePgDelta?: boolean; + readonly env?: Readonly>; readonly format?: OutputFormat; readonly isLocal?: boolean; readonly linkedRef?: string; @@ -107,8 +110,7 @@ interface SetupOpts { // polling — the shadow-source branch's equivalent of `neverHealthyShadow`. readonly neverConnectableShadow?: boolean; // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass - // `Option.none()` to exercise the config.toml/workdir-basename fallback - // (`resolveLocalProjectId`). + // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option; // Simulates an unlinked workdir: `loadProjectRef` fails with // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. @@ -403,6 +405,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { // override its real implementations, matching `start.integration.test.ts`. BunServices.layer, out.layer, + configValuesLayer({ + output: out.layer, + flags: + opts.usePgDelta === undefined + ? [] + : [flagInput("experimental.pgdelta.enabled", "use-pg-delta", opts.usePgDelta)], + env: opts.env, + }), telemetry.layer, cache.layer, pgDeltaEngine, @@ -595,7 +605,10 @@ describe("db diff", () => { }); it.effect("creates the labeled Deno-cache volume before the migra run mounts it", () => { - const s = setup(tmp.current, { diffSql: "create table players ();\n" }); + const s = setup(tmp.current, { + diffSql: "create table players ();\n", + env: { SUPABASE_PROJECT_ID: "test" }, + }); return Effect.gen(function* () { yield* dbDiff(flags({ useMigra: Option.some(true) })); expect(s.edgeCalls[0]?.binds).toEqual(["supabase_edge_runtime_test:/root/.cache/deno:rw"]); @@ -629,7 +642,7 @@ describe("db diff", () => { }); it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { - const s = setup(tmp.current, { diffSql: "create table p ();\n" }); + const s = setup(tmp.current, { usePgDelta: true, diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* dbDiff( flags({ usePgDelta: Option.some(true), strictCoverage: true, schema: ["public"] }), @@ -694,6 +707,7 @@ describe("db diff", () => { it.effect("--use-pg-delta overrides [experimental.pgdelta] enabled = false", () => { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/config.toml": "[experimental.pgdelta]\nenabled = false\n" }, diffSql: "create table p ();\n", }); @@ -736,6 +750,7 @@ describe("db diff", () => { const ignoredDeclarativeNote = (config: string, file: string) => Effect.gen(function* () { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/config.toml": config, [file]: "create table declared ();\n" }, diffSql: "", }); @@ -757,6 +772,37 @@ describe("db diff", () => { }), ); + it.effect("reads a string-typed [experimental.pgdelta] enabled in the declarative advisory", () => + Effect.gen(function* () { + const err = yield* ignoredDeclarativeNote( + '[experimental.pgdelta]\nenabled = "false"\n', + "supabase/schemas/public.sql", + ); + expect(err).toContain("Run supabase db schema declarative sync --experimental to generate"); + expect(err).toContain("or pass --use-migra to diff them with migra"); + }), + ); + + it.effect("keeps the diff successful when the advisory cannot read the config file value", () => + Effect.gen(function* () { + const s = setup(tmp.current, { + usePgDelta: true, + files: { + "supabase/config.toml": '[db.seed]\nenabled = "banana"\n', + "supabase/.env": "SUPABASE_DB_SEED_ENABLED=true\n", + "supabase/schemas/public.sql": "create table declared ();\n", + }, + diffSql: "", + }); + + yield* dbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("declared") })).pipe( + Effect.provide(s.layer), + ); + + expect(stderr(s.out)).toContain("declarative schema files in supabase/schemas are not read"); + }), + ); + it.effect( "suggests --use-migra for an absolute path to supabase/schemas with pg-delta disabled", () => @@ -811,6 +857,7 @@ describe("db diff", () => { it.effect("pg-delta local diff ignores schema_paths and declarative files", () => { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/config.toml": [ "[db.migrations]", @@ -878,6 +925,95 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("--use-pg-delta=false beats [experimental.pgdelta] enabled in config", () => { + const s = setup(tmp.current, { + ...writeSchemaPathsConfig(true), + usePgDelta: false, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ usePgDelta: Option.some(false) })); + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--use-pg-delta=false beats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED and config together", + () => { + const s = setup(tmp.current, { + ...writeSchemaPathsConfig(true), + usePgDelta: false, + env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "true" }, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ usePgDelta: Option.some(false) })); + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED beats [experimental.pgdelta] enabled in config", + () => { + const s = setup(tmp.current, { + ...writeSchemaPathsConfig(true), + env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags()); + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false selects migra without a pgdelta section", + () => { + const s = setup(tmp.current, { + env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags()); + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("ignores SUPABASE_EXPERIMENTAL_PG_DELTA, so the pg-delta default stays on", () => { + const s = setup(tmp.current, { + env: { SUPABASE_EXPERIMENTAL_PG_DELTA: "false" }, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags()); + expect(s.databaseDiffCalls).toHaveLength(1); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "rejects an unparseable SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED instead of ignoring it", + () => { + const s = setup(tmp.current, { + env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "banana" }, + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + const exit = yield* dbDiff(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { const s = setup(tmp.current, { files: { @@ -1505,6 +1641,7 @@ describe("db diff", () => { it.effect("writes live-only SQL with --file even when declarative targets are configured", () => { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/config.toml": [ "[db.migrations]", @@ -1538,6 +1675,7 @@ describe("db diff", () => { it.effect("includes the ignored declarative baseline advisory in JSON output", () => { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/schemas/items.sql": "create table items ();\n", }, @@ -1568,6 +1706,7 @@ describe("db diff", () => { it.effect("ignores declarative inspection errors without changing diff success", () => { const s = setup(tmp.current, { + usePgDelta: true, files: { "supabase/config.toml": [ "[experimental.pgdelta]", @@ -1591,6 +1730,7 @@ describe("db diff", () => { it.effect("writes one migration file per unit for a multi-unit pg-delta plan", () => { const s = setup(tmp.current, { + usePgDelta: true, format: "json", diffFiles: [ { name: "ignored", sql: "alter type mood add value 'ok';" }, @@ -1942,7 +2082,7 @@ describe("db diff", () => { }); it.effect("fails on engine-flag conflict (--use-migra with --use-pg-delta)", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { usePgDelta: true }); return Effect.gen(function* () { const exit = yield* dbDiff( flags({ useMigra: Option.some(true), usePgDelta: Option.some(true) }), @@ -2001,6 +2141,7 @@ describe("db diff", () => { it.effect("warns on semantic data-loss hazards without a DROP statement", () => { const sql = "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;"; const s = setup(tmp.current, { + usePgDelta: true, diffSql: sql, hazards: { actions: [{ actionIndex: 0, kinds: ["data_loss"] }], @@ -2583,7 +2724,7 @@ describe("db diff", () => { it.effect( "fails on engine-flag conflict (--use-pgadmin with --use-pg-delta), byte-exact message", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { usePgDelta: true }); return Effect.gen(function* () { const error = yield* dbDiff( flags({ usePgAdmin: Option.some(true), usePgDelta: Option.some(true) }), @@ -2634,7 +2775,7 @@ describe("db diff", () => { it.live( "removes the shadow container on interruption during the health wait for --use-pgadmin too", () => { - const s = setup(tmp.current, { neverHealthyShadow: true }); + const s = setup(tmp.current, { usePgDelta: true, neverHealthyShadow: true }); return Effect.gen(function* () { const fiber = yield* dbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( Effect.provide(s.layer), @@ -2682,6 +2823,7 @@ describe("db diff", () => { Effect.gen(function* () { const path = yield* Path.Path; const s = setup(tmp.current, { + usePgDelta: engine === "pg-delta", files: { "supabase/config.toml": "[experimental.pgdelta]\nenabled = true\n" }, statefulDocker: true, diffSql: "create table t ();\n", diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 7e74f52875..8e0399ee43 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -7,14 +7,14 @@ image), to stdout or `--file`. ## Files Read -| Path | Format | When | -| --------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `supabase/config.toml` | TOML | always (db port/password/major_version, project_id) | -| `supabase/.temp/postgres-version` | plain text | always (best-effort) — pins the pg image tag when present | -| `supabase/.temp/pooler-url` | plain text | `--linked` when the direct host is unreachable (pooler URL) | -| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | -| `supabase/.temp/project-ref` | plain text | `--linked` (and the default target) ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`/config.toml `project_id`) is set | -| `supabase/.env*` | dotenv | always (project env, feeds `SUPABASE_DB_PASSWORD` / `PG*`) | +| Path | Format | When | +| --------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `supabase/config.json` or `config.toml` | JSON / TOML | always, `config.json` preferred (db port/password/major_version, project_id) | +| `supabase/.temp/postgres-version` | plain text | always (best-effort) — pins the pg image tag when present | +| `supabase/.temp/pooler-url` | plain text | `--linked` when the direct host is unreachable (pooler URL) | +| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | `--linked` (and the default target) ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`/config.toml `project_id`) is set | +| `supabase/.env*` | dotenv | always (project env, feeds `SUPABASE_DB_PASSWORD` / `PG*`; shell values win) | ## Files Written @@ -35,7 +35,7 @@ image), to stdout or `--file`. | Variable | Purpose | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_DB_PASSWORD` (`--password`/`-p` overrides) | remote DB password | +| `SUPABASE_DB_PASSWORD` (`--password`/`-p` overrides) | remote DB password; ignored for a target other than the linked project (see Notes) | | `SUPABASE_ACCESS_TOKEN` | `--linked` auth | | `BITBUCKET_CLONE_DIR` | (no-op for dump — no `--security-opt` is set) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | rewrite the pg image registry for compose dumps; stack dumps use the catalog image pin unchanged | @@ -50,6 +50,7 @@ image), to stdout or `--file`. | `0` | success | | `1` | `--use-copy`/`--exclude` without `--data-only`; mutually-exclusive flags; bad `--file` path; connection failure; container or bundled `pg_dump`/`pg_dumpall` exit ≠ 0 | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `1` | `--password` with `--db-url` or `--local` | ## Output @@ -81,6 +82,11 @@ shell inherits the suppressing variables and is missed. ## Notes +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`; a matched `[remotes.*]` block over the base document on + `--linked`) > default. The linked-database password env is withheld when the target differs + from `.temp/project-ref`: stderr gets `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .` and a temporary login role is minted. `--password` is rejected with + `--db-url` or `--local`, which carry their own credentials. - `--data-only` XOR `--role-only`; `--keep-comments` XOR `--data-only`; `--schema` XOR `--role-only`; `--db-url` XOR `--linked` XOR `--local`. `--use-copy` / `--exclude` require `--data-only`. `--linked` defaults to true. @@ -89,7 +95,8 @@ shell inherits the suppressing variables and is missed. `project_id` > `.temp/project-ref`) — it does not affect any local container id. It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded - flag (stricter than `SUPABASE_PROJECT_ID`, which is simply left unused on a non-linked target). + flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which is simply + unused on a non-linked target). - **Container-level pooler fallback.** When a linked dump reaches the direct host from the host process but the `pg_dump` container fails over IPv6, the captured container stderr is classified (`isIPv6ConnectivityError`) and the dump is diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 42abd7d09f..5576547329 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -2,6 +2,8 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; @@ -10,6 +12,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { DbDumpRunError } from "./dump.errors.ts"; import { dbDump } from "./dump.handler.ts"; import { dbDumpRuntimeLayer } from "./dump.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; /** * `db dump` has no `--output-format` machine envelope; it streams pg_dump SQL to @@ -84,11 +87,12 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), schema: Flag.String("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), @@ -133,4 +137,6 @@ export const dbDumpCommand = Command.make("dump", config).pipe( ), ), Command.provide(dbDumpRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 072f5407da..0f2919b92f 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -6,8 +6,11 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConnType } from "../../../command-internal/db-target-flags.ts"; -import { loadProjectEnv, readDbToml } from "../../../command-internal/db-config.toml-read.ts"; +import { + rejectPasswordWithDirectTarget, + type DbConnType, +} from "../../../command-internal/db-target-flags.ts"; +import { loadProjectEnvValues, readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; import { resolveDbImage } from "../../../command-internal/db-image.ts"; import { @@ -95,7 +98,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); // Resolves grouped boolean flags' effective values (default false) for code paths // that need the value, not just presence. @@ -151,6 +154,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { : useLocal ? "local" : "linked"; + yield* rejectPasswordWithDirectTarget(connType, flags.password); // `--project-ref` never implies `--linked`; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* new DbDumpMutuallyExclusiveFlagsError({ diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 5455eb3138..35818f8b20 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -25,6 +25,7 @@ import { useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { DnsResolverFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { @@ -430,6 +431,7 @@ function setup(opts: SetupOpts = {}) { const docker = mockDockerRun(opts); const bundled = mockBundledPostgresClient(opts); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, out.layer, resolver.layer, projectRef.layer, @@ -576,6 +578,34 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("rejects --password combined with --db-url", () => { + const { layer, resolver } = setup(); + return Effect.gen(function* () { + const exit = yield* dbDump( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe( + "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres", + ); + expect(resolver.calls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --password combined with --local", () => { + const { layer, resolver } = setup(); + return Effect.gen(function* () { + const exit = yield* dbDump( + flags({ local: Option.some(true), password: Option.some("pw") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe( + "--password can't be used with --local. The local database uses [db].password from supabase/config.toml.", + ); + expect(resolver.calls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.live("rejects --linked=false --local as a target conflict", () => { const { layer } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index b99cbe438e..dd874159fa 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -5,6 +5,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { dbLint } from "./lint.handler.ts"; import { dbLintRuntimeLayer } from "./lint.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -71,4 +73,6 @@ export const dbLintCommand = Command.make("lint", config).pipe( ), ), Command.provide(dbLintRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts index 6c80704857..2168584e0a 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts @@ -30,6 +30,7 @@ import { mockTelemetryStateLayer, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -109,6 +110,7 @@ function ambientStubs() { mockCommandCredentialsLayer, mockLinkedProjectCacheLayer, mockTelemetryStateLayer, + cliConfigValuesTestLayer, heavyServiceStubs, ); } diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 042739b133..ef0ab246d5 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -34,18 +34,18 @@ disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | migration-style pull's shadow provisioning, and declarative / deprecated-`--experimental` export config/env resolution | -| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | -| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative` provisions no shadow); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| managed snapshot key `` | backend | warm stack-shadow cache hit; managed retention may evict entries and snapshots survive stack destruction | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| ----------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always, `config.json` preferred (db port/password, `[experimental.pgdelta]`) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | migration-style pull's shadow provisioning, and declarative / deprecated-`--experimental` export config/env resolution | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | +| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative` provisions no shadow); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| managed snapshot key `` | backend | warm stack-shadow cache hit; managed retention may evict entries and snapshots survive stack destruction | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -89,7 +89,7 @@ publishes only when the roles file is unchanged, and retention keeps three entri A falsy `SUPABASE_SHADOW_CACHE` bypasses this cache. A failed warm restore recreates the database; a failed cache export warns and continues with the live shadow. -On by default; setting `SUPABASE_SHADOW_CACHE` to anything not boolean-true (`false`/`0`/empty/garbage, +On by default; setting `SUPABASE_SHADOW_CACHE` to anything that does not parse as true (`false`/`0`/empty/garbage, honored from the ambient env AND the project's dotenv, e.g. `supabase/.env`) turns it off, restoring the documented uncached lifecycle. A warm hit skips the platform baseline, so the `Initialising schema...` progress line does not print — @@ -126,7 +126,7 @@ at all, so nothing is cached for it. | Variable | Purpose | Required? | | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`); ignored for a target other than the linked project (see Notes) | no | | `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | | `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | @@ -134,6 +134,7 @@ at all, so nothing is cached for it. | `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB tags the catalog does not pin, flag-off `15.8.1.085`, and `deno_version = 1` stay on docker.io | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled` (default true; `false` selects migra for the migration-style diff unless `--diff-engine` is passed, and lets a declarative export write `schema_paths`); a value that is not a boolean fails the command | no | | `SUPABASE_EXPERIMENTAL` | selects the deprecated in-process structured-dump export (same as `--declarative`) when `--declarative` is not set | no | ## Exit Codes @@ -173,6 +174,12 @@ Progress strings still go to stderr; stdout carries a single structured envelope ## Notes +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`) > default. The whole config is decoded up front, so an + invalid value fails the command. A `--linked` target also applies a matching `[remotes.*]` + block. The linked-database password env is withheld when the target differs from + `.temp/project-ref`, with a `Not sending SUPABASE_DB_PASSWORD to : ...` warning and a + temporary login role instead. `--password` is rejected with `--db-url` or `--local`. - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. - `--diff-engine` defaults to pg-delta. Without the flag, migra is selected only by @@ -183,11 +190,15 @@ Progress strings still go to stderr; stdout carries a single structured envelope `SUPABASE_PROJECT_ID`, it does not affect the shadow container's project id/labels. It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded - flag (stricter than `SUPABASE_PROJECT_ID`, which is simply left unused on a - non-linked target). The deprecated + flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which is simply + unused on a non-linked target). The deprecated `--experimental` export honors `--project-ref` the same way `--declarative` does. -- `--use-pg-delta` is hidden and emits a deprecation line to stderr. +- `--use-pg-delta` is hidden, selects the declarative export, and prints + `Flag --use-pg-delta has been deprecated, use --declarative instead.` + to stderr. It also sets `[experimental.pgdelta].enabled` at the flag tier, so the declarative + export leaves `[db.migrations].schema_paths` alone and, if the pull falls back to migration mode + (`--use-pg-delta --declarative=false`, last flag wins), the migration diff uses pg-delta. - Migration-style pulls always compare migrations with the live target; declarative files and `schema_paths` do not replace that baseline. - Bundled nontransactional files begin with diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 4fdcdd1e7d..114ed17059 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -1,11 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPull } from "./pull.handler.ts"; import { dbPullRuntimeLayer } from "./pull.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { name: Argument.String("migration name").pipe( @@ -21,13 +24,11 @@ const config = { ), Flag.optional, ), - usePgDelta: Flag.Boolean("use-pg-delta").pipe( - Flag.withDescription("Use pg-delta to pull declarative schema."), - // Hidden: Effect V4 has no `Flag.withDeprecated`; the handler prints - // the deprecation line. - Flag.withHidden, - Flag.optional, - ), + usePgDelta: CliConfigKeys.experimental.pgdelta.enabled.flag({ + name: "use-pg-delta", + description: "Use pg-delta to pull declarative schema.", + hidden: true, + }), diffEngine: Flag.Literals("diff-engine", ["migra", "pg-delta"] as const).pipe( Flag.withDescription("Diff engine to use for migration-style db pull."), Flag.optional, @@ -66,11 +67,12 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; export type DbPullFlags = CliCommand.Command.Config.Infer; @@ -104,4 +106,6 @@ export const dbPullCommand = Command.make("pull", config).pipe( ), ), Command.provide(dbPullRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 01e06c2745..3abe64e589 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -36,6 +36,7 @@ import { mockStdin, mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -96,6 +97,8 @@ const pgDeltaDiffEnvelope = ( }); interface SetupOpts { + readonly usePgDelta?: boolean; + readonly env?: Readonly>; readonly nextDebugDirectory?: string; readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; @@ -127,8 +130,7 @@ interface SetupOpts { readonly networkId?: string; readonly platform?: NodeJS.Platform; // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass - // `Option.none()` to exercise the config.toml/workdir-basename fallback - // (`resolveLocalProjectId`). + // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option; // Simulates an unlinked workdir: `loadProjectRef` fails with // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. @@ -448,6 +450,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { // override its real implementations, matching `start.integration.test.ts`. BunServices.layer, out.layer, + configValuesLayer({ + output: out.layer, + flags: + opts.usePgDelta === undefined + ? [] + : [flagInput("experimental.pgdelta.enabled", "use-pg-delta", opts.usePgDelta)], + env: opts.env, + }), telemetry.layer, cache.layer, pgDeltaEngine, @@ -834,12 +844,31 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false selects migra for a migration-style pull", + () => { + const s = setup(tmp.current, { + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }, + }); + return Effect.gen(function* () { + yield* dbPull(flags()); + expect(s.engineCalls).toHaveLength(0); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("creates the labeled Deno-cache volume before the migra run mounts it", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + env: { SUPABASE_PROJECT_ID: "test" }, }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("migra") })); @@ -1022,7 +1051,7 @@ describe("db pull", () => { it.effect( "deprecated --use-pg-delta prints the deprecation line and behaves like --declarative", () => { - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + const s = setup(tmp.current, { usePgDelta: true, edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* dbPull(flags({ usePgDelta: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Flag --use-pg-delta has been deprecated"); @@ -1051,9 +1080,8 @@ describe("db pull", () => { it.effect( "a linked [remotes.]'s project_id outranks a conflicting SUPABASE_PROJECT_ID", () => { - // `readDbToml` gates `toml.projectId` behind `remoteOverrideKeys` for the matched - // remote, but `resolveLocalProjectId` tries the raw ambient env first — an ambient - // `SUPABASE_PROJECT_ID` for an unrelated project must not win back over it. + // An ambient `SUPABASE_PROJECT_ID` for an unrelated project must not replace the linked + // remote's ref as the engine's project id. const s = setup(tmp.current, { files: { "supabase/config.toml": [ @@ -1079,6 +1107,7 @@ describe("db pull", () => { // Both flags bind to one variable, so the last occurrence wins — ORing the two // parsed flags would wrongly take the declarative path instead. const s = setup(tmp.current, { + usePgDelta: false, migrations: ["20240101000000"], remoteVersions: ["20240101000000"], files: { "supabase/config.toml": "[experimental.pgdelta]\nenabled = false\n" }, @@ -1094,25 +1123,30 @@ describe("db pull", () => { ); it.effect( - "--use-pg-delta --declarative=false stays in migration mode (last occurrence wins)", + "--use-pg-delta --declarative=false stays in migration mode and diffs with pg-delta", () => { const s = setup(tmp.current, { + usePgDelta: true, migrations: ["20240101000000"], remoteVersions: ["20240101000000"], files: { "supabase/config.toml": "[experimental.pgdelta]\nenabled = false\n" }, - edgeStdout: "create table remote ();\n", + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "create table remote ();" }, + ]), yes: true, args: ["db", "pull", "--use-pg-delta", "--declarative=false"], }); return Effect.gen(function* () { yield* dbPull(flags({ declarative: Option.some(false), usePgDelta: Option.some(true) })); expect(s.historyUpserts.length).toBe(1); + expect(s.engineCalls[0]?.operation).toBe("diff"); }).pipe(Effect.provide(s.layer)); }, ); it.effect("--declarative --use-pg-delta (both true) takes the declarative export path", () => { const s = setup(tmp.current, { + usePgDelta: true, edgeStdout: EXPORT_JSON, args: ["db", "pull", "--declarative", "--use-pg-delta"], }); @@ -1791,6 +1825,7 @@ describe("db pull", () => { it.effect("--experimental still exports when the last --declarative alias is false", () => { const s = setup(tmp.current, { + usePgDelta: false, experimental: true, edgeStdout: EXPORT_JSON, args: ["db", "pull", "--experimental", "--declarative", "--use-pg-delta=false"], @@ -1975,6 +2010,24 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false in supabase/.env selects migra for a migration-style pull", + () => { + const s = setup(tmp.current, { + migrations: ["20240101000000"], + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false\n" }, + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags()); + expect(s.engineCalls).toHaveLength(0); + expect(s.edgeCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("--diff-engine pg-delta overrides enabled = false", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 91312a245a..720dafdcad 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -6,15 +6,16 @@ before migrations unless `--skip-vault` is set. ## Files Read -| Path | Format | When | -| ------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | -| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | -| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | -| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | -| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | -| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| Path | Format | When | +| ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always; `config.json` is preferred when both exist (embedded defaults used when neither exists) | +| `/.env*`, `/supabase/.env*` | dotenv | project env files, resolved with `SUPABASE_ENV` (default `development`); a variable the shell sets is never taken from a file | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written @@ -42,26 +43,32 @@ before migrations unless `--skip-vault` is set. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | -| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position) — see Notes | no | -| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | +| Variable | Purpose | Required? | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection; ignored for a target other than the linked project (see Notes) | no (`--password`/`-p` takes precedence) | +| `SUPABASE_DB_SEED_ENABLED` | overrides `[db.seed].enabled`, including a matched `[remotes.*]` block; lifts a `false` but does not make push seed without `--include-seed` | no | +| `SUPABASE_*` config overrides | any `config.toml` key an env name exists for (e.g. `SUPABASE_DB_MIGRATIONS_ENABLED`); see Notes | no | +| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | +| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position) — see Notes | no | +| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------- | -| `0` | success (including "up to date") | -| `1` | mutually exclusive target flags (`[db-url linked local]`) | -| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | -| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | -| `1` | user declined a confirmation prompt (`context canceled`) | -| `1` | `config.toml` parse failure | -| `1` | database connection / migration / seed / roles / vault apply failure | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | seed consent for a `[remotes.*]` match declined (`Seeding cancelled; nothing was changed.`) | +| `1` | seed consent for a `[remotes.*]` match needed but the run can't prompt and `--yes` is absent (`SeedConsentRequiredError`) | +| `1` | `--password` with `--db-url` or `--local` (`--password can't be used with --db-url. …` / `… with --local. …`) | +| `1` | an invalid config value, including an unparsable `SUPABASE_*` override | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -100,8 +107,36 @@ stdout is payload-only. A single `result` object is emitted: target is a hard error rather than a silently discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which simply goes unused on a non-linked target). -- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and - declining returns `context canceled`. +- **Config value precedence** (ADR 0031): explicit flag > shell env > project + `.env*` > config (`config.json` over `config.toml`; a matched `[remotes.*]` + block over the base document) > default. The whole config is decoded up + front, so an invalid value fails the command before any connection. +- **Credential scoping**: the linked-database password env (`SUPABASE_DB_PASSWORD`) + is withheld when the target project differs from the one in + `.temp/project-ref`. The command prints `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .` to stderr and mints a + temporary login role instead. Unlinked workdirs use the env value. +- **`--password`** is rejected with `--db-url` or `--local`, because those + targets carry their own credentials. +- **Seed consent**: when a `--linked`/`--project-ref` target matches a + `[remotes.]` block that does not itself declare `db.seed.enabled = true`, + and there are seeds to apply, the command asks + `Project matches [remotes.]. Run seed file(s) () against it?` + (default no) before the roles prompt. `--yes`/`SUPABASE_YES` answers yes and + prints `Seeding enabled by ` (e.g. `SUPABASE_DB_SEED_ENABLED (shell)`, + `--include-seed`). With a TTY stdin and non-interactive output, or machine + output on a TTY, the run can't prompt and fails with `SeedConsentRequiredError` + (`Seeding ([remotes.]) needs confirmation and this run can't prompt. +Nothing was changed.`) before any write; piped stdin is read for one line, and a pipe that ends + without one (for example `; @@ -89,4 +93,6 @@ export const dbPushCommand = Command.make("push", config).pipe( ), ), Command.provide(dbPushRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/push/push.errors.ts b/apps/cli/src/commands/db/push/push.errors.ts index 36b5c62441..e99dabf19c 100644 --- a/apps/cli/src/commands/db/push/push.errors.ts +++ b/apps/cli/src/commands/db/push/push.errors.ts @@ -51,6 +51,7 @@ export class DbPushMissingRemoteError extends Data.TaggedError("DbPushMissingRem */ export class DbPushCancelledError extends Data.TaggedError("DbPushCancelledError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.cancelled; diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 42e93b72e9..a01240ff6f 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -6,9 +6,14 @@ import { resolveYesWithProjectEnv } from "../../../command-internal/global-flags import { Output } from "../../../shared/output/output.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { checkDbToml, loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; +import { + checkDbToml, + loadProjectEnvValues, +} from "../../../command-internal/db-config.toml-read.ts"; import { dbPushCore } from "../../../command-internal/db-push-core.ts"; +import { resolveDbSeedInput } from "../../../command-internal/seed-remote-consent.ts"; import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -32,12 +37,13 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { const path = yield* Path.Path; const cliArgs = yield* CliArgs; const dnsResolver = yield* DnsResolverFlag; + const configValues = yield* CliConfigValues; const workdir = cliSettings.workdir; // The project `.env` is applied before the history prompt, so a // `SUPABASE_YES` set only in `supabase/.env` auto-confirms. Resolve `yes` // with that project env, as `db pull` does. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); let linkedRefForCache: string | undefined; @@ -85,6 +91,12 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } + const resolvedConfig = yield* configValues.load({ + workdir, + projectRef: projectRef !== "" ? Option.some(projectRef) : Option.none(), + }); + const seed = yield* resolveDbSeedInput(resolvedConfig, { workdir, ref: projectRef }); + const includeSeed = Option.getOrElse(flags.includeSeed, () => false); const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, @@ -101,7 +113,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { "db.push.dry_run": flags.dryRun, "db.push.include_all": flags.includeAll, "db.push.include_roles": flags.includeRoles, - "db.push.include_seed": flags.includeSeed, + "db.push.include_seed": includeSeed, }); yield* dbPushCore({ @@ -113,7 +125,8 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { dryRun: flags.dryRun, includeAll: flags.includeAll, includeRoles: flags.includeRoles, - includeSeed: flags.includeSeed, + includeSeed, + seed, includeVault: !flags.skipVault, dnsResolver, toml, diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 4e31912e44..b4698dd267 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { VALID_REF, mockCommandSettings, @@ -49,7 +50,7 @@ const LOCAL_CONN: PgConnInput = { const DEFAULT_FLAGS: DbPushFlags = { includeAll: false, includeRoles: false, - includeSeed: false, + includeSeed: Option.none(), skipVault: false, dryRun: false, dbUrl: Option.none(), @@ -159,6 +160,8 @@ function setup( piped?: string; args?: ReadonlyArray; yes?: boolean; + includeSeed?: boolean; + env?: Readonly>; isLocal?: boolean; projectRef?: string; linkedFails?: boolean; @@ -227,6 +230,11 @@ function setup( const layer = Layer.mergeAll( workdirLayer, out.layer, + configValuesLayer({ + output: out.layer, + flags: opts.includeSeed === true ? [flagInput("db.seed.enabled", "include-seed", true)] : [], + env: opts.env, + }), conn.layer, resolver.layer, mockCommandSettings({ @@ -260,6 +268,12 @@ const failSuggestion = ( ): string | undefined => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.suggestion : undefined; +const failError = (exit: Exit.Exit): { _tag: string; message: string } => + (Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined) as { + _tag: string; + message: string; + }; + const MIGRATION_DIR = "supabase/migrations"; const migrationFile = (version: string, body = "create table t ();") => ({ [`${MIGRATION_DIR}/${version}_test.sql`]: body, @@ -543,12 +557,15 @@ describe("db push", () => { it.live("seeds a new file with --include-seed", () => { const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); expect( conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), @@ -560,6 +577,7 @@ describe("db push", () => { // Directories are walked recursively; without expansion the path would reach // `readFileString()` and fail. const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["seeds"]\n', files: { "supabase/seeds/a.sql": "insert into t values (1);", @@ -569,7 +587,9 @@ describe("db push", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("Seeding data from supabase/seeds/a.sql..."); expect(out.stderrText).toContain("Seeding data from supabase/seeds/nested/b.sql..."); expect(out.stderrText).not.toContain("notes.txt"); @@ -581,12 +601,15 @@ describe("db push", () => { const body = "insert into t values (1);"; const hash = createHash("sha256").update(body).digest("hex"); const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": body }, remoteSeeds: { "supabase/seed.sql": hash }, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toBe("Local database is up to date.\n"); }); }); @@ -598,6 +621,7 @@ describe("db push", () => { const raw = Buffer.from([0x2d, 0x2d, 0x20, 0xff, 0xfe, 0x00, 0x01, 0x0a]); const rawHash = createHash("sha256").update(raw).digest("hex"); const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', remoteSeeds: { "supabase/seed.sql": rawHash }, }); @@ -606,21 +630,241 @@ describe("db push", () => { const path = yield* Path.Path; yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); yield* fs.writeFile(path.join(tmp.current, "supabase", "seed.sql"), raw); - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toBe("Local database is up to date.\n"); }).pipe(Effect.provide(BunServices.layer)); }); - it.live("skips seeding when disabled in config", () => { - const { layer, out } = setup(tmp.current, { + it.live("seeds despite db.seed.enabled = false when --include-seed is passed", () => { + const { layer, conn } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain( - "Skipping seed because it is disabled in config.toml for project:", + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), ); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }); + }); + + it.live("does not seed without --include-seed even when SUPABASE_DB_SEED_ENABLED=true", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + }); + return Effect.gen(function* () { + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.queries.some((q) => q.sql === SELECT_SEEDS)).toBe(false); + }); + }); + + describe("seeding into a target that matched a [remotes.*] block", () => { + const remoteSeed = ( + opts: { + yes?: boolean; + format?: OutputFormat; + confirm?: ReadonlyArray; + piped?: string; + migrations?: boolean; + dryRun?: boolean; + remoteBlock?: string; + } = {}, + ) => + setup(tmp.current, { + includeSeed: true, + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n${opts.remoteBlock ?? ""}`, + files: { + "supabase/seed.sql": "insert into t values (1);", + ...(opts.migrations === true ? migrationFile("20240101000000") : {}), + }, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: VALID_REF, + yes: opts.yes, + format: opts.format, + confirm: opts.confirm, + piped: opts.piped, + }); + const flags = { + ...DEFAULT_FLAGS, + local: false, + linked: true, + includeSeed: Option.some(true), + }; + const seeded = (conn: ReturnType) => + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")); + + it.live("asks first, naming the ref, the remote and the files, defaulting to no", () => { + const { layer, out, conn } = remoteSeed({ confirm: [true] }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls[0]?.message).toBe( + `Project ${VALID_REF} matches [remotes.preview]. Run 1 seed file (supabase/seed.sql) against it?`, + ); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("asks about seeding once after the matched-remote consent is given", () => { + const { layer, out, conn } = remoteSeed({ confirm: [true] }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("cancels without a bare context canceled before any write when declined", () => { + const { layer, out, conn } = remoteSeed({ confirm: [false], migrations: true }); + return Effect.gen(function* () { + const exit = yield* dbPush(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit).message).toBe("Seeding cancelled; nothing was changed."); + expect(failSuggestion(exit)).toBe( + "Pass --yes to seed, or drop --include-seed to push migrations only.", + ); + expect(out.stderrText).not.toContain("context canceled"); + expect(conn.execs).not.toContain("BEGIN"); + expect(seeded(conn)).toBe(false); + }); + }); + + it.live("proceeds without asking when --yes is passed, naming what enabled seeding", () => { + const { layer, out, conn } = remoteSeed({ yes: true }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls.some((call) => call.message.includes("[remotes."))).toBe( + false, + ); + expect(out.stderrText).toContain("Seeding enabled by --include-seed"); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("fails with SeedConsentRequiredError before any write when non-interactive", () => { + const { layer, out, conn } = remoteSeed({ format: "json", migrations: true }); + return Effect.gen(function* () { + const exit = yield* dbPush(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)._tag).toBe("SeedConsentRequiredError"); + expect(failError(exit).message).toBe( + `Seeding ${VALID_REF} ([remotes.preview]) needs confirmation and this run can't prompt. Nothing was changed.`, + ); + expect(failSuggestion(exit)).toContain("--yes"); + expect(out.promptConfirmCalls).toEqual([]); + expect(conn.execs).not.toContain("BEGIN"); + expect(seeded(conn)).toBe(false); + }); + }); + + it.live("fails with SeedConsentRequiredError when piped stdin ends without an answer", () => { + const { layer, conn } = remoteSeed({ piped: "" }); + return Effect.gen(function* () { + const exit = yield* dbPush(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)._tag).toBe("SeedConsentRequiredError"); + expect(failError(exit).message).toBe( + `Seeding ${VALID_REF} ([remotes.preview]) needs confirmation and this run can't prompt. Nothing was changed.`, + ); + expect(failSuggestion(exit)).toBe( + "Pass --yes to seed, or drop --include-seed to push migrations only.", + ); + expect(conn.execs).not.toContain("BEGIN"); + expect(seeded(conn)).toBe(false); + }); + }); + + it.live.each(["n\n", "\n"])("declines a piped answer of %j without seeding", (piped) => { + const { layer, conn } = remoteSeed({ piped }); + return Effect.gen(function* () { + const exit = yield* dbPush(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)._tag).toBe("DbPushCancelledError"); + expect(failError(exit).message).toBe("Seeding cancelled; nothing was changed."); + expect(conn.execs).not.toContain("BEGIN"); + expect(seeded(conn)).toBe(false); + }); + }); + + it.live("seeds when a piped y answers the prompt", () => { + const { layer, conn } = remoteSeed({ piped: "y\n" }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("does not ask when the matched remote block itself enables seeding", () => { + const { layer, out, conn } = remoteSeed({ + format: "json", + remoteBlock: "\n[remotes.preview.db.seed]\nenabled = true\n", + }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toEqual([]); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("does not ask when the matched remote block enables seeding with a number", () => { + const { layer, out, conn } = remoteSeed({ + format: "json", + remoteBlock: "\n[remotes.preview.db.seed]\nenabled = 1\n", + }); + return Effect.gen(function* () { + yield* dbPush(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toEqual([]); + expect(seeded(conn)).toBe(true); + }); + }); + + it.live("--dry-run says a real run will ask, without prompting or writing", () => { + const { layer, out, conn } = remoteSeed({ migrations: true }); + return Effect.gen(function* () { + yield* dbPush({ ...flags, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + `A real run will ask before seeding ${VALID_REF} ([remotes.preview]).`, + ); + expect(out.promptConfirmCalls).toEqual([]); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("--dry-run says a real run needs --yes when nothing can prompt", () => { + const { layer, out } = remoteSeed({ format: "json", migrations: true }); + return Effect.gen(function* () { + yield* dbPush({ ...flags, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + `A real run will need --yes to seed ${VALID_REF} ([remotes.preview]).`, + ); + }); + }); + + it.live("does not ask when the target matched no remote block", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); + expect(out.promptConfirmCalls.some((call) => call.message.includes("[remotes."))).toBe( + false, + ); + expect(seeded(conn)).toBe(true); + }); }); }); @@ -652,6 +896,7 @@ describe("db push", () => { it.live("emits the seeded file paths in the json success payload", () => { const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), @@ -660,7 +905,9 @@ describe("db push", () => { format: "json", }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["upToDate"]).toBe(false); expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); @@ -698,12 +945,13 @@ describe("db push", () => { it.live("returns context canceled when the seed prompt is declined", () => { const { layer } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, confirm: [false], }); return Effect.gen(function* () { - const exit = yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + const exit = yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( Effect.provide(layer), Effect.exit, ); @@ -714,6 +962,7 @@ describe("db push", () => { it.live("re-hashes a dirty seed without re-running its statements", () => { const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, // Remote hash differs → dirty. @@ -721,7 +970,9 @@ describe("db push", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); expect(conn.execs).not.toContain("insert into t values (1);"); }); @@ -729,23 +980,29 @@ describe("db push", () => { it.live("treats every seed as pending when the seed_files table is absent", () => { const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, noSeedTable: true, confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); }); }); it.live("warns and reports up to date when no seed files match", () => { const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); expect(out.stdoutText).toBe("Local database is up to date.\n"); }); @@ -753,12 +1010,15 @@ describe("db push", () => { it.live("reports seed files up to date when migrations push but no seeds match", () => { const { layer, out } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', files: migrationFile("20240101000000"), confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain("Seed files are up to date."); }); }); @@ -922,6 +1182,7 @@ describe("db push", () => { it.live("dry-run lists roles, migrations and seeds without applying", () => { const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), @@ -934,7 +1195,7 @@ describe("db push", () => { ...DEFAULT_FLAGS, dryRun: true, includeRoles: true, - includeSeed: true, + includeSeed: Option.some(true), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Would create custom roles"); expect(out.stderrText).toContain("roles.sql"); @@ -964,7 +1225,7 @@ describe("db push", () => { confirm: [true], }); return Effect.gen(function* () { - // No config.toml written → loadCliConfig returns null → default config + // No config.toml written → the resolved config reports no config file → default config // (migrations enabled), and the vault document is absent. yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); @@ -1006,6 +1267,7 @@ describe("db push", () => { toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', files: migrationFile("20240101000000"), confirm: [true], + env: { SEED_ENABLED: "true" }, }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1016,10 +1278,7 @@ describe("db push", () => { ); }); - it.live("a matched remote block's migrations.enabled beats the shell env override", () => { - // A matched [remotes.] block overrides the shell env, so - // `[remotes.preview.db.migrations] enabled = false` wins over - // `SUPABASE_DB_MIGRATIONS_ENABLED=true`. + it.live("the shell env override beats a matched remote block's migrations.enabled", () => { const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n\n[remotes.preview.db.migrations]\nenabled = false\n`, files: migrationFile("20240101000000"), @@ -1030,12 +1289,9 @@ describe("db push", () => { }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Skipping migrations because it is disabled"); - expect(out.stderrText).not.toContain("Applying migration 20240101000000"); - }).pipe( - (body) => withEnvVar("SUPABASE_DB_MIGRATIONS_ENABLED", "true", body), - (body) => withConfigEnv({ SUPABASE_DB_MIGRATIONS_ENABLED: "true" }, body), - ); + expect(out.stderrText).not.toContain("Skipping migrations because it is disabled"); + expect(out.stderrText).toContain("Applying migration 20240101000000"); + }).pipe((body) => withEnvVar("SUPABASE_DB_MIGRATIONS_ENABLED", "true", body)); }); it.live("announces a matching [remotes.*] override on the linked path", () => { @@ -1051,6 +1307,36 @@ describe("db push", () => { }); }); + it.live( + "selects a [remotes.*] block whose project_id is overridden by SUPABASE_REMOTES__PROJECT_ID", + () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${FLAG_PROJECT_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: VALID_REF, + env: { SUPABASE_REMOTES_PREVIEW_PROJECT_ID: VALID_REF }, + }); + return Effect.gen(function* () { + yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }, + ); + + it.live("leaves a [remotes.*] block unselected when its project_id is not the target", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${FLAG_PROJECT_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: VALID_REF, + }); + return Effect.gen(function* () { + yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("Loading config override"); + }); + }); + it.live("pushes to the linked project and caches the project ref (json)", () => { const { layer, out, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 41befd4df2..d2b031556f 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -6,6 +6,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { QUERY_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; import { dbQuery } from "./query.handler.ts"; import { dbQueryRuntimeLayer } from "./query.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; /** * `db query` needs its own `--output`/`-o` (`json|table|csv`), but Effect CLI keeps one global @@ -70,4 +72,6 @@ export const dbQueryCommand = Command.make("query", config).pipe( ), ), Command.provide(dbQueryRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 7f209bbf22..b34e0a5f7e 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -34,7 +34,7 @@ import { PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/project-ref.service.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; -import { ProjectRefReadError } from "../../../command-internal/temp-paths.ts"; +import { ProjectRefReadError } from "../../../shared/config/temp-paths.ts"; import { TelemetryOutputFormat } from "../../../telemetry/telemetry-output-format.service.ts"; import { DbConfigParseUrlError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; diff --git a/apps/cli/src/commands/db/remote/changes/changes.command.ts b/apps/cli/src/commands/db/remote/changes/changes.command.ts index ecbcf7febe..82de4cb8b6 100644 --- a/apps/cli/src/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/commands/db/remote/changes/changes.command.ts @@ -1,5 +1,7 @@ import { Command, Flag } from "effect/cli"; import { removedCommand } from "../../../../command-internal/removed-command.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -19,11 +21,12 @@ const config = { Flag.withDescription("Connect to the linked project."), Flag.withDefault(false), ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; export const dbRemoteChangesCommand = Command.make("changes", config).pipe( @@ -37,4 +40,5 @@ export const dbRemoteChangesCommand = Command.make("changes", config).pipe( ), Command.provide(commandRuntimeLayer(["db", "remote", "changes"])), Command.provide(telemetryStateLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/remote/commit/commit.command.ts b/apps/cli/src/commands/db/remote/commit/commit.command.ts index 47016f3f17..5b7d74f8b5 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -1,11 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../../command-internal/schema-flags.ts"; import { dbSchemaPullRuntimeLayer } from "../../pull/pull.layers.ts"; import { dbRemoteCommit } from "./commit.handler.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; const config = { schema: Flag.String("schema").pipe( @@ -25,11 +28,12 @@ const config = { Flag.withDescription("Connect to the linked project."), Flag.withDefault(false), ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; export type DbRemoteCommitFlags = CliCommand.Command.Config.Infer; @@ -55,4 +59,6 @@ export const dbRemoteCommitCommand = Command.make("commit", config).pipe( ), ), Command.provide(dbSchemaPullRuntimeLayer(["db", "remote", "commit"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index af4aa19a64..9a11723cdc 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -23,9 +23,8 @@ reset-local-database.ts`'s `resetLocalDatabase` (CLI-2062), which this handler's own `cfg.isLocal` branch calls as a thin wrapper (keeping only version/ seed-flags resolution and the JSON envelope, which are specific to this top-level command). `db schema declarative`'s smart-target local-reset prompt and `db schema -sync`'s failed-apply recovery reset both call the SAME function in-process now, -instead of shelling out to a second child process — see those commands' own -`SIDE_EFFECTS.md`. +sync`'s failed-apply recovery reset both call the SAME function in-process — see those +commands' own `SIDE_EFFECTS.md`. When the `experimental.stack` feature flag is on (`SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence, same rules as [`docs/stack-commands.md`](../../../../docs/stack-commands.md)), the @@ -62,19 +61,19 @@ or migrations fail, companion services remain stopped. Fix the reported error an ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | -| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | -| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | -| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | -| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | -| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated | -| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the local path's running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | +| Path | Format | When | +| -------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always, parsed and decoded up front before any destructive work (`config.json` preferred; embedded defaults when neither exists); re-read for local bucket seeding | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | +| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated | +| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the local path's running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | ## Files Written @@ -101,7 +100,7 @@ equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). | `docker container inspect ` + `docker exec kong reload --nginx-conf /home/kong/custom_nginx.template` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) — the `--nginx-conf` flag is load-bearing: a bare `kong reload` regenerates nginx.conf from Kong's default template and drops the custom `email_templates` server (#6059) | | `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | -No subprocess delegation occurs on either target — the remote path's +No subprocess delegation on either target — the remote path's `--experimental` schema-files apply is fully native. ## Database Mutations @@ -163,11 +162,12 @@ the whole reset** (not just "skip buckets"). | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | local path: ambient shell environment only (project dotenv files deliberately never override Docker client keys) — resolves the daemon endpoint for the running probe (in-process) and steers the spawned `docker`/`podman` CLI itself | no | | `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection; ignored for a target other than the linked project (see Notes) | no | +| `SUPABASE_DB_SEED_ENABLED` | overrides `[db.seed].enabled` (flag > env > matched `[remotes.*]` > base); honoured on the local and remote paths | no | | `SUPABASE_YES` | auto-confirm the reset prompt and the local path's bucket-seed overwrite/prune prompts (shell or project dotenv, same as `seed buckets`) | no (also `--yes`) | | `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | -| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | -| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled` (default true); `false` opens the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) so `--experimental` applies `schema_paths` files instead of timestamped migrations — switches between two different destructive code paths | no | +| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (beats the config-file value and a matched `[remotes.*]` block) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | | `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (project `.env` or shell) | no (project `.env` or shell) | | `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB tags the catalog does not pin, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) | @@ -198,13 +198,17 @@ echoed, because those may genuinely have reached the server. | `1` | `--version` has no matching migration file | | `1` | local: database not running (`supabase start is not running.`) | | `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `--linked` seed consent for a `[remotes.*]` match declined or unattended without `--yes` (`context canceled`, suggests `--yes`) | +| `1` | `--password` with `--db-url` or `--local` | +| `1` | an invalid config value, including an unparsable `SUPABASE_*` override | | `1` | `config.toml` parse failure | | `1` | drop / migrate / seed / vault apply failure, or connection error | | `1` | no `[db.migrations].schema_paths` pattern matched anything on the `--experimental` branch, either target | | `1` | local: container/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | -There is no child process on either target — every failure is a native, typed TS error surfaced as `1`. +Neither target delegates to a child process — every failure is a native, typed error +surfaced as `1`. ## Output @@ -269,6 +273,18 @@ to those defaults (the usual outcome for an interactive terminal). standalone outside the per-file transaction batch, with the same non-atomic flush behaviour as `db push` — see `db push`'s SIDE_EFFECTS Notes (supabase/cli#5139, adopted into TS in PR supabase/cli#5671). +- **Config value precedence** (ADR 0031): explicit flag > shell env > project + `.env*` > config (`config.json` over `config.toml`; a matched `[remotes.*]` + block over the base document) > default. The linked-database password env is + withheld when the target differs from `.temp/project-ref`: stderr gets + `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .` and a temporary login role is minted. +- **Seed consent (remote)**: after the `Do you want to reset the remote database?` prompt and before any change, a `--linked` target that matched a + `[remotes.]` block that does not itself declare `db.seed.enabled = true`, with seeding enabled and at least one seed file, asks + `Project matches [remotes.]. Run seed file(s) () against it?` (default no). `--yes` or + `SUPABASE_YES` answers yes and prints `Seeding enabled by ` (for example `SUPABASE_DB_SEED_ENABLED (shell)`); a TTY stdin with + non-interactive output, or machine output on a TTY, fails with `SeedConsentRequiredError` + (`Seeding ([remotes.]) needs confirmation and this run can't prompt. Nothing was changed.`, suggesting `--yes` or `--no-seed`); + piped stdin is read for one line, and a pipe that ends without one fails the same way. An answer of no exits 1 with `Seeding cancelled; nothing was changed.` - `--no-seed` forces seeding off; on the local path it feeds `resolveResetSeedConfig`, applied on top of the loaded `[db.seed]` config inside the recreate's own `MigrateAndSeed` step (same override @@ -299,9 +315,8 @@ to those defaults (the usual outcome for an interactive terminal). progress line is printed per file, no migration-history row is inserted, and no `RESET ALL` runs between files. Seeding still runs afterward, unconditionally, exactly as on the migrations branch. The - local target's branch is `migrateAndSeed` (reused by both the PG14 and PG15 - recreate branches); the remote target's copy of the same branch is - `applySchemaFiles`. + local target's branch is `migrateAndSeed`, reused by both the PG14 and PG15 recreate + branches; the remote target's copy of the same branch is `applySchemaFiles`. `encrypted:` vault secrets are NOT skipped on the remote path — `checkDbToml` decrypts them into `toml.vault`, and `upsertVaultSecrets` upserts the decrypted values unconditionally, before either branch (schema-files or migrations) @@ -311,6 +326,6 @@ to those defaults (the usual outcome for an interactive terminal). `schema_paths` is not applied and that `[experimental.pgdelta] enabled = false` restores it (before the confirmation prompt on the remote path). - `db schema declarative`/`db schema sync`'s own local-reset paths call - `resetLocalDatabase` in-process too (CLI-2062). That in-process call collapses to a + `resetLocalDatabase` in-process too, so the reset runs inside a single telemetry/linked-project-cache finalizer cycle (the outer `db schema declarative`/`sync` command's own). diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 646e810c85..67b5c033a2 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -1,12 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbReset } from "./reset.handler.ts"; import { dbResetRuntimeLayer } from "./reset.layers.ts"; - -const noSqlPaths: ReadonlyArray = []; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -27,17 +28,17 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - noSeed: Flag.Boolean("no-seed").pipe( - Flag.withDescription("Skip running the seed script after reset."), - Flag.withDefault(false), - ), - sqlPaths: Flag.String("sql-paths").pipe( - Flag.atLeast(0), - Flag.withDescription( + noSeed: CliConfigKeys.db.seed.enabled.flag({ + name: "no-seed", + description: "Skip running the seed script after reset.", + map: (skip) => (skip ? false : undefined), + }), + sqlPaths: CliConfigKeys.db.seed.sqlPaths.flag({ + name: "sql-paths", + description: "Override [db.seed].sql_paths for this reset. May be repeated; each value accepts a SQL file path or glob pattern relative to the supabase directory and force-enables seeding.", - ), - Flag.withDefault(noSqlPaths), - ), + also: [[CliConfigKeys.db.seed.enabled, true]], + }), version: Flag.String("version").pipe( Flag.withDescription("Reset up to the specified version."), Flag.optional, @@ -73,4 +74,6 @@ export const dbResetCommand = Command.make("reset", config).pipe( ), ), Command.provide(dbResetRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/reset/reset.errors.ts b/apps/cli/src/commands/db/reset/reset.errors.ts index 535b7f40b1..cc765d4343 100644 --- a/apps/cli/src/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/commands/db/reset/reset.errors.ts @@ -62,6 +62,7 @@ export class DbResetMigrationFileError extends Data.TaggedError("DbResetMigratio */ export class DbResetCancelledError extends Data.TaggedError("DbResetCancelledError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.cancelled; diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index ae0bc6f561..242a51b927 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -7,15 +7,25 @@ import { resolveYesWithProjectEnv, } from "../../../command-internal/global-flags.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; +import { + SEED_CANCELLED_MESSAGE, + confirmSeedIntoMatchedRemote, + resolveDbSeedInput, + seedCancelledSuggestion, +} from "../../../command-internal/seed-remote-consent.ts"; +import { sqlFilesGlob } from "../../../command-internal/sql-files-glob.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { aqua, yellow } from "../../../command-internal/colors.ts"; -import { resolveResetSeedConfig } from "../../../command-internal/db-bootstrap/db-setup.ts"; import { resetLocalDatabase } from "../../../command-internal/db-bootstrap/reset-local-database.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { checkDbToml, loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; +import { + checkDbToml, + loadProjectEnvValues, +} from "../../../command-internal/db-config.toml-read.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { experimentalSchemaPathsIgnoredWarning, @@ -43,6 +53,8 @@ import { DbResetVersionFlagsError, } from "./reset.errors.ts"; +const NO_SQL_PATHS: ReadonlyArray = []; + const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; const applyError = (message: string, suggestion?: string) => @@ -69,11 +81,14 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const path = yield* Path.Path; const cliArgs = yield* CliArgs; const dnsResolver = yield* DnsResolverFlag; + const configValues = yield* CliConfigValues; + const noSeed = Option.getOrElse(flags.noSeed, () => false); + const sqlPaths = Option.getOrElse(flags.sqlPaths, () => NO_SQL_PATHS); const workdir = cliSettings.workdir; const migrationsDir = path.join(workdir, "supabase", "migrations"); // Load the project values before the `yes`/`experimental` gates are read. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); let linkedRefForCache: string | undefined; @@ -103,7 +118,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // `--no-seed` conflicts with `--sql-paths`, and each `--sql-paths` value // must be non-empty. - if (flags.noSeed && flags.sqlPaths.length > 0) { + if (noSeed && sqlPaths.length > 0) { return yield* new DbResetSeedFlagsError({ message: "--no-seed cannot be used with --sql-paths", suggestion: `Use either ${aqua("--no-seed")} to skip seeding or ${aqua( @@ -111,7 +126,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { )} to override seed files, not both.`, }); } - if (flags.sqlPaths.some((p) => p.length === 0)) { + if (sqlPaths.some((p) => p.length === 0)) { return yield* new DbResetSeedFlagsError({ message: "--sql-paths requires a non-empty path or glob pattern", suggestion: `Pass a non-empty file path or glob pattern to ${aqua("--sql-paths")}.`, @@ -119,7 +134,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { } // A remote target flag + --sql-paths warns about the seed override. if ( - flags.sqlPaths.length > 0 && + sqlPaths.length > 0 && (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) ) { yield* output.raw( @@ -201,10 +216,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // git-branch line) is hoisted into `resetLocalDatabase`, shared with `db schema declarative`'s // recovery reset; this call site keeps only version/seed-flags plumbing and the JSON envelope. if (cfg.isLocal) { - yield* resetLocalDatabase({ - version: resolvedVersion, - seedFlags: { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, - }); + yield* resetLocalDatabase({ version: resolvedVersion }); if (output.format !== "text") { yield* output.success("Reset local database.", { target: "local", @@ -228,6 +240,13 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } const vaultSecrets = toml.vault; + const resolvedConfig = yield* configValues.load({ + workdir, + projectRef: Option.fromNullishOr(configRef), + }); + const seed = yield* resolveDbSeedInput(resolvedConfig, { workdir, ref: linkedRef ?? "" }); + const seedEnabled = seed.enabled; + const seedSqlPaths = seed.sqlPaths; if ( ignoresExperimentalSchemaPaths({ experimental, @@ -249,6 +268,23 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { if (!shouldReset) { return yield* new DbResetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } + if (seedEnabled && seed.consent !== undefined) { + const { files } = yield* sqlFilesGlob(fs, path, seedSqlPaths, workdir); + if (files.length > 0) { + const seedConsented = yield* confirmSeedIntoMatchedRemote({ + command: "reset", + target: seed.consent, + files, + yes, + }); + if (!seedConsented) { + return yield* new DbResetCancelledError({ + message: SEED_CANCELLED_MESSAGE, + suggestion: seedCancelledSuggestion("reset"), + }); + } + } + } yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); // Established output contract: no "Connecting to ... database..." line is printed here. @@ -287,16 +323,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* applyMigrations(session, fs, path, pending, applyError); } - // `--no-seed` disables seeding; `--sql-paths` overrides `[db.seed].sql_paths` and - // force-enables it (mutually exclusive, validated above). Shares `resolveResetSeedConfig` - // with the local path's identical override. - const resolvedSeed = resolveResetSeedConfig( - toml.seed, - { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, - path, - ); - if (resolvedSeed.enabled) { - const seeds = yield* getPendingSeeds(session, fs, path, resolvedSeed.sqlPaths, workdir); + if (seedEnabled) { + const seeds = yield* getPendingSeeds(session, fs, path, seedSqlPaths, workdir); yield* Effect.annotateCurrentSpan({ "seed.count": seeds.length }); yield* seedData(session, fs, workdir, path, seeds, applyError); } diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index d1999ab640..02cd36af62 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -40,6 +40,7 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; +import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; @@ -106,8 +107,8 @@ const DEFAULT_FLAGS: DbResetFlags = { linked: false, local: false, projectRef: Option.none(), - noSeed: false, - sqlPaths: [], + noSeed: Option.none(), + sqlPaths: Option.none(), version: Option.none(), last: Option.none(), }; @@ -863,6 +864,10 @@ function setup( execFailsOn?: string; execFailsMessage?: string; yes?: boolean; + interactive?: boolean; + noSeed?: boolean; + sqlPaths?: ReadonlyArray; + env?: Readonly>; omitRef?: boolean; resolveFails?: boolean; // Local-reset-only knobs. @@ -923,7 +928,11 @@ function setup( }), ).pipe(Layer.provide(BunServices.layer)); - const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: opts.format ?? "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirm, + }); const conn = mockConnection(opts); const telemetry = mockTelemetryStateTracked(); const linkedCache = mockLinkedProjectCacheTracked(); @@ -987,8 +996,18 @@ function setup( : Effect.succeed(jsonResponse(request, matched.status ?? 200, matched.body ?? {})); }), ); + const seedFlagInputs = [ + ...(opts.noSeed === true ? [flagInput("db.seed.enabled", "no-seed", false)] : []), + ...(opts.sqlPaths === undefined + ? [] + : [ + flagInput("db.seed.sql_paths", "sql-paths", opts.sqlPaths), + flagInput("db.seed.enabled", "sql-paths", true), + ]), + ]; const layer = Layer.mergeAll( out.layer, + configValuesLayer({ output: out.layer, flags: seedFlagInputs, env: opts.env }), conn.layer, resolver.layer, mockCommandSettings({ workdir }), @@ -1145,19 +1164,70 @@ describe("db reset", () => { it.live("skips seeding with --no-seed on a local reset", () => { const { layer, conn } = setup(tmp.current, { + noSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, args: ["db", "reset", "--local"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(conn.execs.some((sql) => sql.includes("insert into t values (1)"))).toBe(false); }); }); + it.live("seeds on a local reset when SUPABASE_DB_SEED_ENABLED beats a disabled config", () => { + const { layer, conn } = setup(tmp.current, { + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (3);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("insert into t values (3)"))).toBe(true); + }); + }); + + it.live("skips seeding on a local reset when SUPABASE_DB_SEED_ENABLED is false", () => { + const { layer, conn } = setup(tmp.current, { + env: { SUPABASE_DB_SEED_ENABLED: "false" }, + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (4);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("insert into t values (4)"))).toBe(false); + }); + }); + + it.live("lets --sql-paths beat SUPABASE_DB_SEED_ENABLED=false on a local reset", () => { + const { layer, conn } = setup(tmp.current, { + env: { SUPABASE_DB_SEED_ENABLED: "false" }, + sqlPaths: ["custom-seed.sql"], + toml: 'project_id = "test"\n', + files: { "supabase/custom-seed.sql": "insert into t values (5);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* dbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: Option.some(["custom-seed.sql"]), + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("insert into t values (5)"))).toBe(true); + }); + }); + it.live("seeds from --sql-paths overriding config on a local reset", () => { const { layer, conn } = setup(tmp.current, { + sqlPaths: ["custom-seed.sql"], toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/custom-seed.sql": "insert into t values (2);" }, args: ["db", "reset", "--local"], @@ -1167,7 +1237,7 @@ describe("db reset", () => { yield* dbReset({ ...DEFAULT_FLAGS, local: true, - sqlPaths: ["custom-seed.sql"], + sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (2)"))).toBe(true); }); @@ -2238,13 +2308,16 @@ describe("db reset", () => { it.live("passes --no-seed and the resolved version to the final MigrateAndSeed step", () => { const { layer, conn } = setup(tmp.current, { + noSeed: true, toml: PG14_TOML, files: { "supabase/seed.sql": "insert into t values (9);" }, args: ["db", "reset", "--local"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: Option.some(true) }).pipe( + Effect.provide(layer), + ); expect(conn.execs.some((sql) => sql.includes("insert into t values (9)"))).toBe(false); }); }); @@ -2422,6 +2495,7 @@ describe("db reset", () => { toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', files: migrationFile("20240101000000"), confirm: [true], + env: { MIGRATIONS_ENABLED: "true" }, }); return withEnvVar( "MIGRATIONS_ENABLED", @@ -2747,6 +2821,7 @@ describe("db reset", () => { it.live("skips seeding with --no-seed", () => { const { layer, out } = setup(tmp.current, { + noSeed: true, toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), @@ -2755,7 +2830,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + yield* dbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: Option.some(true) }).pipe( Effect.provide(layer), ); expect(out.stderrText).not.toContain("Seeding data from"); @@ -3163,8 +3238,8 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - noSeed: true, - sqlPaths: ["seed.sql"], + noSeed: Option.some(true), + sqlPaths: Option.some(["seed.sql"]), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -3180,6 +3255,7 @@ describe("db reset", () => { "applies configured schema files and skips seeding on an experimental remote --db-url reset", () => { const { layer, conn, resolver } = setup(tmp.current, { + noSeed: true, toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, experimental: true, @@ -3190,7 +3266,7 @@ describe("db reset", () => { yield* dbReset({ ...DEFAULT_FLAGS, dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), - noSeed: true, + noSeed: Option.some(true), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(conn.execs.some((s) => s.includes("insert into"))).toBe(false); @@ -3296,14 +3372,209 @@ describe("db reset", () => { }); }); + describe("seeding into a target that matched a [remotes.*] block", () => { + const REMOTE_TOML = `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n`; + const remoteSeed = ( + opts: { + yes?: boolean; + format?: OutputFormat; + confirm?: ReadonlyArray; + env?: Readonly>; + sqlPaths?: ReadonlyArray; + remoteBlock?: string; + interactive?: boolean; + pipedStdin?: string; + } = {}, + ) => + setup(tmp.current, { + toml: `${REMOTE_TOML}${opts.remoteBlock ?? ""}`, + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + "supabase/custom-seed.sql": "insert into t values (2);", + }, + sqlPaths: "sqlPaths" in opts ? opts.sqlPaths : ["custom-seed.sql"], + yes: opts.yes, + format: opts.format, + interactive: opts.interactive, + confirm: opts.confirm, + env: opts.env, + ...(opts.pipedStdin === undefined + ? {} + : { pipedStdin: opts.pipedStdin, stdinIsTty: false }), + }); + const flags = { ...DEFAULT_FLAGS, linked: true, sqlPaths: Option.some(["custom-seed.sql"]) }; + const seeded = (out: { readonly stderrText: string }) => + out.stderrText.includes("Seeding data from supabase/custom-seed.sql..."); + const askedAboutRemote = (out: ReturnType) => + out.promptConfirmCalls.some((call) => call.message.includes("[remotes.")); + + it.live( + "asks after the reset prompt, naming the ref, remote and files, defaulting to no", + () => { + const { layer, out } = remoteSeed({ confirm: [true, true] }); + return Effect.gen(function* () { + yield* dbReset(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls[1]?.message).toBe( + `Project ${VALID_REF} matches [remotes.preview]. Run 1 seed file (supabase/custom-seed.sql) against it?`, + ); + expect(out.promptConfirmCalls[1]?.opts?.defaultValue).toBe(false); + expect(seeded(out)).toBe(true); + }); + }, + ); + + it.live("cancels without a bare context canceled before any write when declined", () => { + const { layer, out, conn } = remoteSeed({ confirm: [true, false] }); + return Effect.gen(function* () { + const exit = yield* dbReset(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + message: "Seeding cancelled; nothing was changed.", + suggestion: "Pass --yes to seed, or --no-seed to reset without seeding.", + }); + } + expect(out.stderrText).not.toContain("context canceled"); + expect(out.stderrText).not.toContain("Resetting remote database"); + expect(conn.execs).toEqual([]); + }); + }); + + it.live("proceeds without asking when --yes is passed, naming what enabled seeding", () => { + const { layer, out } = remoteSeed({ yes: true }); + return Effect.gen(function* () { + yield* dbReset(flags).pipe(Effect.provide(layer)); + expect(askedAboutRemote(out)).toBe(false); + expect(out.stderrText).toContain("Seeding enabled by --sql-paths"); + expect(seeded(out)).toBe(true); + }); + }); + + it.live("fails with SeedConsentRequiredError before any write when non-interactive", () => { + const { layer, out, conn } = remoteSeed({ interactive: false, confirm: [true] }); + return Effect.gen(function* () { + const exit = yield* dbReset(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SeedConsentRequiredError", + message: `Seeding ${VALID_REF} ([remotes.preview]) needs confirmation and this run can't prompt. Nothing was changed.`, + suggestion: expect.stringContaining("--yes"), + }); + } + expect(out.stderrText).not.toContain("Resetting remote database"); + expect(conn.execs).toEqual([]); + }); + }); + + it.live( + "fails with SeedConsentRequiredError when piped stdin ends after the reset answer", + () => { + const { layer, out, conn } = remoteSeed({ pipedStdin: "y\n" }); + return Effect.gen(function* () { + const exit = yield* dbReset(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SeedConsentRequiredError", + suggestion: "Pass --yes to seed, or --no-seed to reset without seeding.", + }); + } + expect(out.stderrText).not.toContain("Resetting remote database"); + expect(conn.execs).toEqual([]); + }); + }, + ); + + it.live("declines a piped n at the seed prompt without writing", () => { + const { layer, out, conn } = remoteSeed({ pipedStdin: "y\nn\n" }); + return Effect.gen(function* () { + const exit = yield* dbReset(flags).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + message: "Seeding cancelled; nothing was changed.", + }); + } + expect(out.stderrText).not.toContain("Resetting remote database"); + expect(conn.execs).toEqual([]); + }); + }); + + it.live("seeds when piped y answers both prompts", () => { + const { layer, out } = remoteSeed({ pipedStdin: "y\ny\n" }); + return Effect.gen(function* () { + yield* dbReset(flags).pipe(Effect.provide(layer)); + expect(seeded(out)).toBe(true); + }); + }); + + it.live("does not ask when the matched remote block itself enables seeding", () => { + const { layer, out } = remoteSeed({ + interactive: false, + confirm: [true], + remoteBlock: "\n[remotes.preview.db.seed]\nenabled = true\n", + sqlPaths: undefined, + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(askedAboutRemote(out)).toBe(false); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("does not ask when the matched remote leaves seeding disabled", () => { + const { layer, out } = setup(tmp.current, { + toml: REMOTE_TOML, + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(askedAboutRemote(out)).toBe(false); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("does not ask when the target matched no remote block", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + sqlPaths: ["custom-seed.sql"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbReset(flags).pipe(Effect.provide(layer)); + expect(askedAboutRemote(out)).toBe(false); + expect(seeded(out)).toBe(true); + }); + }); + + it.live("SUPABASE_DB_SEED_ENABLED beats the remote's disabled default and still asks", () => { + const { layer, out } = remoteSeed({ + confirm: [true, true], + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + sqlPaths: undefined, + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(askedAboutRemote(out)).toBe(true); + }); + }); + }); + it.live("rejects --no-seed together with --sql-paths", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true, - noSeed: true, - sqlPaths: ["seed.sql"], + noSeed: Option.some(true), + sqlPaths: Option.some(["seed.sql"]), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -3318,7 +3589,7 @@ describe("db reset", () => { const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true, - sqlPaths: [""], + sqlPaths: Option.some([""]), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -3348,6 +3619,7 @@ describe("db reset", () => { it.live("seeds an absolute --sql-paths file on a remote reset", () => { const { layer, out } = setup(tmp.current, { + sqlPaths: [`${tmp.current}/external-seed.sql`], toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), confirm: [true], @@ -3360,7 +3632,7 @@ describe("db reset", () => { yield* dbReset({ ...DEFAULT_FLAGS, linked: true, - sqlPaths: [absSeed], + sqlPaths: Option.some([absSeed]), }).pipe(Effect.provide(layer)); // Seed paths are reported forward-slashed on every platform, drive letter included. expect(out.stderrText).toContain( @@ -3371,6 +3643,7 @@ describe("db reset", () => { it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { const { layer, out } = setup(tmp.current, { + sqlPaths: ["custom-seed.sql"], // Seed disabled in config — --sql-paths must force-enable it. toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { @@ -3383,7 +3656,7 @@ describe("db reset", () => { yield* dbReset({ ...DEFAULT_FLAGS, linked: true, - sqlPaths: ["custom-seed.sql"], + sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); @@ -3394,6 +3667,7 @@ describe("db reset", () => { "seeds from --sql-paths on an experimental remote reset, independently of the schema-files apply", () => { const { layer, out, conn } = setup(tmp.current, { + sqlPaths: ["custom-seed.sql"], toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", @@ -3406,7 +3680,7 @@ describe("db reset", () => { yield* dbReset({ ...DEFAULT_FLAGS, linked: true, - sqlPaths: ["custom-seed.sql"], + sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 1860b0fa64..b2e31c5938 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -41,7 +41,7 @@ const toml: DbTomlValues = { shadowPort: 54320, password: "postgres", poolerConnectionString: Option.none(), - projectId: Option.none(), + projectId: "test", majorVersion: 17, orioledbVersion: Option.none(), denoVersion: 2, @@ -64,7 +64,6 @@ const toml: DbTomlValues = { seed: { enabled: true, sqlPaths: [] }, vault: [], appliedRemote: undefined, - remoteOverrideKeys: new Set(), }; function jsonParseErrorMessage(raw: string): string { diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index 413a99378b..b7bd4672bb 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -6,11 +6,13 @@ import { } from "../../../../command-internal/global-flags.ts"; import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { CliConfigValues } from "../../../../config/cli-config-values.service.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { resetLocalDatabase } from "../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/project-ref.service.ts"; import { currentStackBackend } from "../../../../command-internal/stack-backend.ts"; import { DbConfigResolver } from "../../../../command-internal/db-config.service.ts"; -import { loadProjectEnv } from "../../../../command-internal/db-config.toml-read.ts"; +import { loadProjectEnvValues } from "../../../../command-internal/db-config.toml-read.ts"; import { layeredParseEnv, parseConnectionString, @@ -48,8 +50,13 @@ export interface SmartTargetFlags { const localConnection = Effect.fnUntraced(function* (local: LocalConn) { // Host resolution order: SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1. + const { workdir } = yield* CommandSettings; + const resolvedConfig = yield* (yield* CliConfigValues).load({ + workdir, + projectRef: Option.none(), + }); return { - host: yield* getHostname(), + host: yield* getHostname(resolvedConfig), port: local.port, user: "postgres", password: local.password, @@ -135,7 +142,7 @@ export const resolveSmartTargetEndpoint = Effect.fn("DeclarativeSchema.smartTarg const output = yield* Output; // `SUPABASE_YES` — from the shell env or the project `.env` — must auto-confirm the prompts // below too, not just the `--yes` flag. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); // Inserts "Linked project" between local and custom when the workdir is linked with a valid // ref; an invalid on-disk ref hides the choice rather than showing it and failing later. diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts index 69149d5a18..a8ebfe5066 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts @@ -2,6 +2,8 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; import { aqua } from "../../../../../command-internal/colors.ts"; @@ -10,6 +12,7 @@ import { withCommandTelemetry } from "../../../../../telemetry/command-telemetry import { dbSchemaDeclarativeSharedBase } from "../declarative.shared.ts"; import { dbSchemaDeclarativeGenerate } from "./generate.handler.ts"; import { dbSchemaDeclarativeGenerateRuntimeLayer } from "./generate.layers.ts"; +import { cliConfigValuesLayer } from "../../../../../config/cli-config-values.layer.ts"; const config = { overwrite: Flag.Boolean("overwrite").pipe( @@ -56,11 +59,12 @@ const config = { Flag.withDescription("Generates declarative schema from the local database."), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; // `--no-cache` is a shared flag on the `declarative` group (read from the parent), @@ -121,4 +125,6 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi }), ), Command.provide(dbSchemaDeclarativeGenerateRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index f5152f5bc9..024e9f098b 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -10,19 +10,17 @@ import { Output } from "../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../shared/runtime/tty.service.ts"; import { CommandSettings } from "../../../../../config/command-settings.service.ts"; import { bold } from "../../../../../command-internal/colors.ts"; -import { readProjectRefFile } from "../../../../../command-internal/temp-paths.ts"; +import { readProjectRefFile } from "../../../../../shared/config/temp-paths.ts"; import { - loadProjectEnv, + loadProjectEnvValues, readDbToml, resolveDeclarativeDir, } from "../../../../../command-internal/db-config.toml-read.ts"; +import { rejectPasswordWithDirectTarget } from "../../../../../command-internal/db-target-flags.ts"; import { LinkedProjectCache } from "../../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../../telemetry/telemetry-state.service.ts"; import { listLocalMigrations } from "../../../../../command-internal/migration-list.ts"; -import { - isPgDeltaDebugEnabled, - resolvePgDeltaProjectId, -} from "../../../../../command-internal/pgdelta.ts"; +import { isPgDeltaDebugEnabled } from "../../../../../command-internal/pgdelta.ts"; import type { PgDeltaDatabaseEndpoint } from "../../../shared/pgdelta-engine.service.ts"; import { DeclarativeWriteError } from "../../../shared/pgdelta.errors.ts"; import { @@ -62,7 +60,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const dnsResolver = yield* DnsResolverFlag; // The project env is loaded and resolved before the gate below, so a `SUPABASE_EXPERIMENTAL` // set only in `supabase/.env` opens the gate too. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); // `--yes` or `SUPABASE_YES` (shell env or project `.env`) must auto-confirm the prompts below. const yes = yield* resolveYesWithProjectEnv(projectEnv); @@ -95,6 +93,11 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene }); } + yield* rejectPasswordWithDirectTarget( + Option.isSome(flags.dbUrl) ? "db-url" : Option.isSome(flags.local) ? "local" : "linked", + flags.password, + ); + // Explicit `--linked` re-loads config with the resolved ref, so a matching `[remotes.]` // block overrides `experimental.pgdelta.*` downstream only, not the gate above. Smart-mode's // "Linked project" choice does not re-load, so only `flags.linked` triggers this. @@ -138,11 +141,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const run: DeclarativeRunContext = { pgDelta: { - // `resolvePgDeltaProjectId` resolves `SUPABASE_PROJECT_ID` env → config.toml's - // `project_id` → sanitized workdir basename — not `cliSettings.projectId` alone, which - // is env-only and would mount the wrong `supabase_edge_runtime_` Deno-cache volume for a - // project relying on config or the workdir-basename default. - projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), + projectId: toml.projectId, cwd: cliSettings.workdir, // Merged config's deno_version (re-loaded with the linked ref above on // `--linked`), so pg-delta runs under the remote-configured Deno image. @@ -154,7 +153,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 460703ceeb..1e45ec2d43 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -30,6 +30,7 @@ import { mockStdin, mockTty, } from "../../../../../../tests/helpers/mocks.ts"; +import { configValuesLayer } from "../../../../../../tests/helpers/config-values-layer.ts"; import { mockCommandSettings, mockLinkedProjectCacheTracked, @@ -316,6 +317,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const layer = Layer.mergeAll( out.layer, + configValuesLayer({ + output: out.layer, + env: Option.match(opts.projectId ?? Option.some("test"), { + onNone: () => ({}), + onSome: (projectId) => ({ SUPABASE_PROJECT_ID: projectId }), + }), + }), telemetry.layer, cache.layer, seam, @@ -654,6 +662,23 @@ describe("db schema declarative generate integration", () => { }, ); + for (const [label, target] of [ + ["--local", { local: Option.some(true) }], + ["--db-url", { dbUrl: Option.some("postgresql://x") }], + ] as const) { + it.effect(`rejects --password combined with ${label}`, () => { + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + const exit = yield* dbSchemaDeclarativeGenerate( + flags({ ...target, password: Option.some("pw") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ _tag: "DbPasswordFlagsError" }); + expect(s.localPostgresImageChecks).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + } + it.effect("--output-dir protects a non-empty destination without --overwrite", () => { const s = setup(tmp.current, { experimental: true, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts index 443b2f8a26..6923e34c73 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts @@ -2,12 +2,14 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../../../telemetry/command-telemetry.ts"; import { dbSchemaDeclarativeSharedBase } from "../declarative.shared.ts"; import { dbSchemaDeclarativeSync } from "./sync.handler.ts"; import { dbSchemaDeclarativeSyncRuntimeLayer } from "./sync.layers.ts"; +import { cliConfigValuesLayer } from "../../../../../config/cli-config-values.layer.ts"; const config = { schema: Flag.String("schema").pipe( @@ -85,4 +87,6 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( }), ), Command.provide(dbSchemaDeclarativeSyncRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index eea2d51858..71da915489 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -8,6 +8,7 @@ import { import { promptYesNo } from "../../../../../command-internal/prompt-yes-no.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../shared/runtime/tty.service.ts"; +import { CliConfigValues } from "../../../../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../../../../config/command-settings.service.ts"; import { resetLocalDatabase } from "../../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { stackLocalDatabaseConn } from "../../../../../command-internal/stack-local-database.ts"; @@ -16,22 +17,19 @@ import { bold, red, yellow } from "../../../../../command-internal/colors.ts"; import { DbConnection } from "../../../../../command-internal/db-connection.service.ts"; import { getHostname } from "../../../../../command-internal/hostname.ts"; import { - loadProjectEnv, + loadProjectEnvValues, readDbToml, resolveDeclarativeDir, } from "../../../../../command-internal/db-config.toml-read.ts"; import { makeDir } from "../../../../../command-internal/make-dir.ts"; import { applyMigrationFile } from "../../../../../command-internal/migration-apply.ts"; import { ENABLE_LOCAL_WEBHOOKS_SUGGESTION } from "../../../../../command-internal/pg-net-guidance.ts"; -import { readProjectRefFile } from "../../../../../command-internal/temp-paths.ts"; +import { readProjectRefFile } from "../../../../../shared/config/temp-paths.ts"; import { LinkedProjectCache } from "../../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../../telemetry/telemetry-state.service.ts"; import { listLocalMigrations } from "../../../../../command-internal/migration-list.ts"; import { pgDeltaTempPath } from "../../../../../command-internal/pgdelta.paths.ts"; -import { - isPgDeltaDebugEnabled, - resolvePgDeltaProjectId, -} from "../../../../../command-internal/pgdelta.ts"; +import { isPgDeltaDebugEnabled } from "../../../../../command-internal/pgdelta.ts"; import { writePgDeltaMigrations } from "../../../shared/pgdelta-migrations.write.ts"; import { resolveLocalTargetEndpoint, @@ -93,10 +91,11 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cliSettings = yield* CommandSettings; + const configValues = yield* CliConfigValues; const telemetryState = yield* TelemetryState; // The project env is loaded and resolved before the gate below, so a `SUPABASE_EXPERIMENTAL` // set only in `supabase/.env` opens the gate too. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); // `--yes` or `SUPABASE_YES` (shell env or project `.env`) must auto-confirm the prompts below. const yes = yield* resolveYesWithProjectEnv(projectEnv); @@ -143,11 +142,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const tempDir = pgDeltaTempPath(path, cliSettings.workdir); const run: DeclarativeRunContext = { pgDelta: { - // `resolvePgDeltaProjectId` resolves `SUPABASE_PROJECT_ID` env → config.toml's - // `project_id` → sanitized workdir basename — not `cliSettings.projectId` alone, which - // is env-only and would mount the wrong `supabase_edge_runtime_` Deno-cache volume for a - // project relying on config or the workdir-basename default. - projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), + projectId: toml.projectId, cwd: cliSettings.workdir, denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, @@ -157,7 +152,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, }; @@ -589,7 +584,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f ), ) : { - host: yield* getHostname(), + host: yield* getHostname( + yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), + ), port: toml.port, password: toml.password, }; diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index f78a03cef1..7330b2ee0e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -29,6 +29,7 @@ import { mockStdin, mockTty, } from "../../../../../../tests/helpers/mocks.ts"; +import { configValuesLayer } from "../../../../../../tests/helpers/config-values-layer.ts"; import { mockCommandSettings, mockLinkedProjectCacheTracked, @@ -364,6 +365,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const layer = Layer.mergeAll( out.layer, + configValuesLayer({ + output: out.layer, + env: Option.match(opts.projectId ?? Option.some("test"), { + onNone: () => ({}), + onSome: (projectId) => ({ SUPABASE_PROJECT_ID: projectId }), + }), + }), telemetry.layer, cache.layer, seam, diff --git a/apps/cli/src/commands/db/seed-flag-telemetry.integration.test.ts b/apps/cli/src/commands/db/seed-flag-telemetry.integration.test.ts new file mode 100644 index 0000000000..5a07050557 --- /dev/null +++ b/apps/cli/src/commands/db/seed-flag-telemetry.integration.test.ts @@ -0,0 +1,68 @@ +import { BunCrypto } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stdio } from "effect"; + +import { + mockContextualAnalytics, + mockOutput, + mockProcessControl, +} from "../../../tests/helpers/mocks.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; + +const capturedFlags = ( + command: ReadonlyArray, + args: ReadonlyArray, + flags: Record, +) => { + const analytics = mockContextualAnalytics(); + const stdio = Stdio.layerTest({ args: Effect.succeed([...command, ...args]) }); + return Effect.void.pipe( + withCommandTelemetry({ flags }), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + stdio, + commandRuntimeLayer(command).pipe(Layer.provide(BunCrypto.layer)), + ), + ), + Effect.map(() => analytics.captured[0]?.properties.flags), + ); +}; + +describe("seed and pg-delta flag telemetry", () => { + it.live("records --include-seed as a boolean", () => + capturedFlags(["db", "push"], ["--include-seed"], { + "include-seed": Option.some(true), + }).pipe( + Effect.tap((flags) => Effect.sync(() => expect(flags).toEqual({ "include-seed": true }))), + ), + ); + + it.live("records --no-seed as a boolean and redacts --sql-paths values", () => + capturedFlags(["db", "reset"], ["--no-seed", "--sql-paths", "a.sql"], { + "no-seed": Option.some(true), + "sql-paths": Option.some(["a.sql"]), + }).pipe( + Effect.tap((flags) => + Effect.sync(() => expect(flags).toEqual({ "no-seed": true, "sql-paths": "" })), + ), + ), + ); + + it.live("records --use-pg-delta=false as a boolean", () => + capturedFlags(["db", "pull"], ["--use-pg-delta=false"], { + "use-pg-delta": Option.some(false), + }).pipe( + Effect.tap((flags) => Effect.sync(() => expect(flags).toEqual({ "use-pg-delta": false }))), + ), + ); + + it.live("omits flags that were not passed", () => + capturedFlags(["db", "push"], [], { + "include-seed": Option.none(), + }).pipe(Effect.tap((flags) => Effect.sync(() => expect(flags).toBeUndefined()))), + ); +}); diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts index 47cb2bfea7..4d9d932fbb 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { pgDeltaNextEngineLayer } from "./pgdelta-engine.next.layer.ts"; import { PgDeltaEngine } from "./pgdelta-engine.service.ts"; @@ -31,7 +31,7 @@ const toml: DbTomlValues = { shadowPort: 54320, password: "postgres", poolerConnectionString: Option.none(), - projectId: Option.none(), + projectId: "test", majorVersion: 17, orioledbVersion: Option.none(), denoVersion: 2, @@ -54,7 +54,6 @@ const toml: DbTomlValues = { seed: { enabled: true, sqlPaths: [] }, vault: [], appliedRemote: undefined, - remoteOverrideKeys: new Set(), }; function setup() { diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts index 329178cfb5..7bbdaa1e76 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts @@ -9,7 +9,7 @@ import { } from "../../../command-internal/db-config.parse.ts"; import { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import { acquirePgPool } from "../../../command-internal/db-connection.sql-pg.layer.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { filesForDeclarativeShadowLoad, prepareDeclarativeShadow, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts index 3d3652ac43..5469070e26 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { hasBlockingDiagnostics, STRICT_COVERAGE_CODES } from "@supabase/pg-delta/frontends"; import { Output } from "../../../shared/output/output.service.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { PgDeltaEngineError } from "./pgdelta-engine.service.ts"; import type { PgDeltaNextDiagnostic, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.unit.test.ts index c229256f2c..282f7ba3e9 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.unit.test.ts @@ -3,7 +3,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import type { PgDeltaNextDiagnostic } from "./pgdelta-next-adapter.service.ts"; import { PG_DELTA_NEXT_SKIPPED_STATEMENT_CODE, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index 0f5db7067f..7f088c43f0 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -10,6 +10,7 @@ import { import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; @@ -148,6 +149,7 @@ export const pgDeltaNextShadowLayer = Layer.effect( const httpClient = yield* HttpClient.HttpClient; const cliSettings = yield* CommandSettings; const stackApi = yield* StackApi; + const cliConfigValues = yield* CliConfigValues; const runtimeWith = (outputService: typeof Output.Service) => { const deps = Layer.mergeAll( @@ -166,6 +168,7 @@ export const pgDeltaNextShadowLayer = Layer.effect( Layer.succeed(Crypto.Crypto, crypto), Layer.succeed(CommandSettings, cliSettings), Layer.succeed(StackApi, stackApi), + Layer.succeed(CliConfigValues, cliConfigValues), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return Layer.mergeAll(deps, stackCatalogSetupLayer.pipe(Layer.provide(deps))); @@ -196,7 +199,6 @@ export const pgDeltaNextShadowLayer = Layer.effect( runtimeInfo.platform, debug, request.projectRef, - request.toml.remoteOverrideKeys, ); const image = (yield* currentStackBackend).kind === "stack" diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts index 493b2d0312..ccfea2b805 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts @@ -5,6 +5,7 @@ import { Effect, FileSystem, Layer, Option } from "effect"; import { mockCommandSettings, withConfigEnv } from "../../../../tests/helpers/command-mocks.ts"; import { containerEngineSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; @@ -30,7 +31,7 @@ const toml = { shadowPort: 54320, password: "postgres", poolerConnectionString: Option.none(), - projectId: Option.some("test"), + projectId: "test", majorVersion: 17, orioledbVersion: Option.none(), denoVersion: 2, @@ -53,7 +54,6 @@ const toml = { seed: { enabled: false, sqlPaths: [] }, vault: [], appliedRemote: undefined, - remoteOverrideKeys: new Set(), } satisfies DbTomlValues; const fakeDocker = Layer.succeed(DockerRun, { @@ -106,6 +106,7 @@ describe("pg-delta next stack shadow provisioning", () => { Layer.provide(settings), Layer.provide(runtimeInfoLayer), Layer.provide(output), + Layer.provide(cliConfigValuesTestLayer), Layer.provide(Layer.succeed(DebugFlag, false)), Layer.provide(Layer.succeed(ExperimentalFlag, false)), Layer.provide(Layer.succeed(NetworkIdFlag, Option.none())), diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index 918ee4bbb2..8f81e3d36c 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { afterEach, beforeEach, vi } from "vitest"; @@ -12,6 +12,7 @@ import { shadowCacheDisabledLayer, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -125,6 +126,7 @@ function setup( readonly dbInspectFailsWith?: string; readonly dbInspectImage?: string; readonly stackBackend?: boolean; + readonly env?: Readonly>; } = {}, ) { const out = mockOutput(); @@ -144,6 +146,7 @@ function setup( // body itself can resolve `Output`/etc.), but that doesn't satisfy `seam`'s OWN identical // requirements as a sibling entry in the same merge. const seam = declarativeSeamLayer.pipe( + Layer.provide(configValuesLayer({ output: out.layer, env: opts.env })), Layer.provide(cliSettings), Layer.provide(dbConnection.layer), Layer.provide(docker.layer), @@ -247,6 +250,29 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { }, ); + it.effect( + "inspects the container named by SUPABASE_PROJECT_ID over config.toml's project_id", + () => { + const dir = tmp.current; + const { layer, shadowSpawned } = setup(dir, { + dbInspectImage: dockerfileServiceImageRaw("pg"), + env: { SUPABASE_PROJECT_ID: "envproj" }, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + 'project_id = "tomlproj"\n', + ); + const seam = yield* DeclarativeSeam; + yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); + expect(shadowSpawned.some((call) => call.args.includes("supabase_db_envproj"))).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("flags a stale slim container when only its revision has drifted from a hotfix", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = tmp.current; diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 89a64810d2..b3d56ee063 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -2,6 +2,8 @@ import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { ExperimentalFlag } from "../../../command-internal/global-flags.ts"; import { spawnContainerCli } from "../../../command-internal/container-cli.ts"; @@ -13,7 +15,7 @@ import { imageDigest, imageTag, isSlimImageRef } from "../../../shared/services/ import { upstreamVersionFromTag } from "../../../shared/services/services.shared.ts"; import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; -import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; +import { localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; @@ -80,6 +82,7 @@ export const declarativeSeamLayer = Layer.effect( const experimentalFlag = yield* ExperimentalFlag; const cliArgs = yield* CliArgs; const stackCatalogSetup = yield* StackCatalogSetup; + const cliConfigValues = yield* CliConfigValues; const context = yield* Effect.context(); return DeclarativeSeam.of({ @@ -94,6 +97,7 @@ export const declarativeSeamLayer = Layer.effect( Effect.provideService(ExperimentalFlag, experimentalFlag), Effect.provideService(CliArgs, cliArgs), Effect.provideService(StackCatalogSetup, stackCatalogSetup), + Effect.provideService(CliConfigValues, cliConfigValues), Effect.mapError( (cause) => new DeclarativeShadowDbError({ @@ -146,6 +150,7 @@ export const declarativeSeamLayer = Layer.effect( return yield* Effect.scoped( Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir).pipe( + Effect.provideService(CliConfigValues, cliConfigValues), Effect.mapError( (error) => new DeclarativeShadowDbError({ @@ -160,12 +165,18 @@ export const declarativeSeamLayer = Layer.effect( toml.majorVersion, Option.getOrUndefined(toml.orioledbVersion), ); - const tomlProjectId = toml.projectId; - const projectId = resolveLocalProjectId( - Option.getOrUndefined(cliSettings.projectId), - Option.getOrUndefined(tomlProjectId), - cliSettings.workdir, - ); + const projectId = yield* cliConfigValues + .load({ workdir: cliSettings.workdir, projectRef: Option.none() }) + .pipe( + Effect.flatMap((snapshot) => snapshot.get(CliConfigKeys.projectId)), + Effect.map(({ value }) => value), + Effect.mapError( + (error) => + new DeclarativeShadowDbError({ + message: `failed to read config for local Postgres image check: ${error.message}`, + }), + ), + ); const containerId = localDbContainerId(projectId); const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { stdin: "ignore", diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index b5afb92d70..b792f6f776 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -16,9 +16,9 @@ import { bold } from "../../../command-internal/colors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { resolveDeclarativeDir, - resolveSeedSqlPath, type PgDeltaTomlConfig, } from "../../../command-internal/db-config.toml-read.ts"; +import { resolveSeedSqlPath } from "../../../shared/config/seed-path.ts"; import { resolveUnderWorkdir, globPattern, diff --git a/apps/cli/src/commands/db/start/start.command.ts b/apps/cli/src/commands/db/start/start.command.ts index 7475eaf92d..9cfdffe54d 100644 --- a/apps/cli/src/commands/db/start/start.command.ts +++ b/apps/cli/src/commands/db/start/start.command.ts @@ -1,10 +1,12 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbStart } from "./start.handler.ts"; import { dbStartRuntimeLayer } from "./start.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { fromBackup: Flag.String("from-backup").pipe( @@ -29,4 +31,6 @@ export const dbStartCommand = Command.make("start", config).pipe( ), ), Command.provide(dbStartRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index c5a87a46ea..82d8176fbe 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -23,6 +23,7 @@ import { mockProcessControl, mockRuntimeInfo, } from "../../../../tests/helpers/mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { mockCommandSettings, @@ -371,6 +372,7 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( BunServices.layer, out.layer, + configValuesLayer({ output: out.layer }), cliSettings, telemetry.layer, child.layer, @@ -933,7 +935,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.rate_limit"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -966,7 +968,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain(dottedFieldPath); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -989,7 +991,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain(dottedFieldPath); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1012,7 +1014,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain(dottedFieldPath); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1032,7 +1034,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("storage.enabled"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1057,7 +1059,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain(dottedFieldPath); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1117,7 +1119,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.jwt_expiry"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1137,7 +1139,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("api.port"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1173,7 +1175,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain(dottedFieldPath); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1194,7 +1196,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.passkey"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1214,7 +1216,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.external"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1234,7 +1236,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.hook"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1256,7 +1258,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("auth.email.smtp"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1291,7 +1293,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("storage.image_transformation.enabled"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1324,7 +1326,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("db.ssl_enforcement.enabled"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -1344,7 +1346,7 @@ describe("db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = Cause.pretty(exit.cause); - expect(message).toContain("DbConfigLoadError"); + expect(message).toContain("CliConfigValueError"); expect(message).toContain("db.ssl_enforcement.enabled"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 4cbf589f2d..847a2a5aaa 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -7,6 +7,8 @@ import { testDbConfig, } from "../../../command-internal/test-db.command-handler.ts"; import { testDbRuntimeLayer } from "../../../command-internal/test-db.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; /** * `db test` is a hidden alias for `test db`, registered hidden by the parent @@ -23,4 +25,6 @@ export const dbTestCommand = Command.make("test", testDbConfig).pipe( // entry point even though the handler is identical — see // `testDbRuntimeLayer`'s doc comment. Command.provide(testDbRuntimeLayer(["db", "test"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(testDbConfig), ); diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index 38c17a5cfb..3f72c2ac82 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -22,6 +22,7 @@ import { mockTelemetryStateTracked, sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -165,6 +166,7 @@ function setup(opts: SetupOpts = {}) { const docker = mockDockerRun({ exitCode: opts.exitCode, stdout: opts.stdout }); const args = ["db", "test"]; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, unusedStackServices, out.layer, processControl.layer, @@ -218,6 +220,7 @@ describe("db test (alias) integration", () => { const args = ["db", "test", "--local", "--linked"]; const analytics = mockContextualAnalytics(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, unusedStackServices, BunServices.layer, mockRuntimeInfo(), diff --git a/apps/cli/src/commands/experimental/compute/compute-backend.integration.test.ts b/apps/cli/src/commands/experimental/compute/compute-backend.integration.test.ts index febfee62f0..9853dd014d 100644 --- a/apps/cli/src/commands/experimental/compute/compute-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/compute-backend.integration.test.ts @@ -4,10 +4,15 @@ import { Effect, FileSystem, Path } from "effect"; import { respondToComplete } from "../../../cli/complete.ts"; import { rootCommandForFeatures } from "../../../cli/root.ts"; import { resolveComputeEnabled } from "./compute-backend.ts"; +import { withConfigEnv } from "../../../../tests/helpers/command-mocks.ts"; import { ExperimentalFeatureFlagError } from "../../../command-internal/experimental-feature.ts"; -const resolve = (input: Parameters[0]) => - resolveComputeEnabled(input).pipe(Effect.provide(BunServices.layer)); +const resolve = ({ + env, + ...input +}: Parameters[0] & { + readonly env: Readonly>; +}) => withConfigEnv(env, resolveComputeEnabled(input)).pipe(Effect.provide(BunServices.layer)); const project = Effect.fnUntraced(function* (files: Record) { const fs = yield* FileSystem.FileSystem; @@ -109,7 +114,7 @@ describe("resolveComputeEnabled", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.effect("keeps default workdir selection aligned with the TOML command reader", () => { + it.effect("prefers a nested project's config.json over an enclosing config.toml project", () => { return Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -120,7 +125,7 @@ describe("resolveComputeEnabled", () => { path.join(child, "supabase/config.json"), '{"experimental":{"compute":true}}', ); - expect(yield* resolve({ args: ["compute"], cwd: child, env: {} })).toBe(false); + expect(yield* resolve({ args: ["compute"], cwd: child, env: {} })).toBe(true); expect(yield* resolve({ args: ["compute", "--workdir", child], cwd: root, env: {} })).toBe( true, ); diff --git a/apps/cli/src/commands/experimental/compute/compute-backend.ts b/apps/cli/src/commands/experimental/compute/compute-backend.ts index f79d862473..7851ca987c 100644 --- a/apps/cli/src/commands/experimental/compute/compute-backend.ts +++ b/apps/cli/src/commands/experimental/compute/compute-backend.ts @@ -9,7 +9,6 @@ import { extractCommandPath, hasRootVersionFlag } from "../../../shared/cli/run. export const resolveComputeEnabled = (input: { readonly args: ReadonlyArray; readonly cwd: string; - readonly env: Readonly>; }): Effect.Effect => Effect.gen(function* () { const routingArgs = @@ -30,6 +29,5 @@ export const resolveComputeEnabled = (input: { ...input, args: routingArgs, }), - env: input.env, }); }); diff --git a/apps/cli/src/commands/experimental/compute/compute.shared.ts b/apps/cli/src/commands/experimental/compute/compute.shared.ts index b74b60f6f3..522706e654 100644 --- a/apps/cli/src/commands/experimental/compute/compute.shared.ts +++ b/apps/cli/src/commands/experimental/compute/compute.shared.ts @@ -1,7 +1,7 @@ -import { findCliProjectPaths, loadCliConfig } from "@supabase/config/effect"; +import { findCliProjectPaths } from "@supabase/config/effect"; import { Effect, FileSystem, Option, Path, Predicate } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { shouldSearchAncestors } from "../../../command-internal/workdir-search.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { readComputeSection, type ComputeEntry, @@ -37,68 +37,54 @@ export interface ComputeProject { readonly computeDir: string; } -const loadComputeProjectWith = Effect.fnUntraced(function* (options: { - readonly tomlOnly: boolean; -}) { +/** + * The project as a reader sees it, following the loader's normal JSON-over-TOML selection. + * + * A command that only reads `[compute.*]` still has to honour `config.json`, or a JSON project + * deploys with a guessed runtime and default size/instance counts instead of the ones it + * configured. + */ +export const loadComputeProject = Effect.gen(function* () { const settings = yield* CommandSettings; const path = yield* Path.Path; + const configValues = yield* CliConfigValues; - // `tomlOnly` skips the ancestor search (redundant with the default - // resolution); the JSON-capable read needs it so a config.json-only project - // run from a subdirectory is still found. `projectRoot` is derived from the - // same search, not `settings.workdir`, so a climb takes `configPath` with it - // — otherwise a discovered ancestor's `[compute.*]` entries would resolve - // `source` against the wrong directory. - const search = options.tomlOnly ? false : shouldSearchAncestors(settings); - const paths = yield* findCliProjectPaths(settings.workdir, { search }); - const projectRoot = paths?.projectRoot ?? settings.workdir; + const projectRoot = settings.workdir; const supabaseDir = path.join(projectRoot, "supabase"); - // `search: false`: the climb (if any) already happened above; reading - // `projectRoot`'s own config.toml again must never climb a second time. - const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: options.tomlOnly, search: false }); - const section = readComputeSection(loaded?.config.compute); + const resolvedConfig = yield* configValues.load({ + workdir: projectRoot, + projectRef: Option.none(), + }); return { projectRoot, supabaseDir, - configPath: loaded?.path ?? path.join(supabaseDir, "config.toml"), - section, + configPath: resolvedConfig.hasConfigFile + ? resolvedConfig.loaded.path + : path.join(supabaseDir, "config.toml"), + section: readComputeSection(resolvedConfig.materialized.config.compute), computeDir: computeRootDir(path, projectRoot), } satisfies ComputeProject; }); /** - * The project as a reader sees it, following the loader's normal - * JSON-over-TOML selection. + * As {@link loadComputeProject}, for the `[compute.]` entry writer. * - * A command that only reads `[compute.*]` still has to honour `config.json`, - * or a JSON project deploys with a guessed runtime and default size/instance - * counts instead of the ones it configured. - */ -export const loadComputeProject = loadComputeProjectWith({ tomlOnly: false }); - -/** - * The project as the `[compute.]` entry writer needs to see it: TOML - * only. - * - * `commitComputeEntry` is a TOML text editor; without `tomlOnly` a JSON - * project's `configPath` would resolve to `config.json`, and appending a - * `[compute.]` table there would make the file unparseable. + * `commitComputeEntry` is a TOML text editor, so a JSON project is rejected: appending a + * `[compute.]` table to `config.json` would make the file unparseable. */ export const loadComputeProjectForEntryWrite = Effect.fnUntraced(function* () { const settings = yield* CommandSettings; const path = yield* Path.Path; - const paths = yield* findCliProjectPaths(settings.workdir, { - search: shouldSearchAncestors(settings), - }); + const paths = yield* findCliProjectPaths(settings.workdir, { search: false }); if (paths !== null && path.basename(paths.configPath) === "config.json") { return yield* new ComputeJsonConfigUnsupportedError({ detail: `This project is configured by ${paths.configPath}, which the compute new command cannot edit safely.`, suggestion: `Create the source files manually and add the compute entry to ${paths.configPath}, or convert the whole project configuration to TOML before scaffolding it.`, }); } - return yield* loadComputeProjectWith({ tomlOnly: true }); + return yield* loadComputeProject; }); /** diff --git a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 31816fc293..97d3b2f2bb 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, it is absent from help and completion; direct invocation follows the normal unknown-command path and the command handler does not run. See the [Compute command guide](../../../../../docs/compute-commands.md). @@ -66,15 +66,15 @@ rather than deleting unasked. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | -| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts index e6939083e8..ad01d8b174 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts @@ -1,5 +1,7 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -37,4 +39,6 @@ export const computeDeleteCommand = Command.make("delete", config).pipe( computeDelete(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["compute", "delete"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 8e300abeba..2f6ed8be56 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, it is absent from help and completion; direct invocation follows the normal unknown-command path and the command handler does not run. See the [Compute command guide](../../../../../docs/compute-commands.md). @@ -49,14 +49,14 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/list/list.command.ts b/apps/cli/src/commands/experimental/compute/list/list.command.ts index 9e16aaa99a..0917496af3 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.command.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.command.ts @@ -1,5 +1,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -29,4 +31,6 @@ export const computeListCommand = Command.make("list", config).pipe( computeList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["compute", "list"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index e0acfca3b2..a0dcbad80b 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -8,6 +8,7 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { resolveWorkdir } from "../../../../config/command-settings.layer.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { @@ -636,8 +637,6 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // The default workdir resolution only probes config.toml, so a config.json-only - // project invoked from a subdirectory relies on this second climb to be found. it.live( "discovers a config.json-only project's [compute.*] entry from a subdirectory when --workdir is defaulted", () => @@ -655,9 +654,10 @@ describe("compute list", () => { const sub = path.join(created.dir, "nested", "dir"); yield* fs.makeDirectory(sub, { recursive: true }); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), sub, path); const { layer, out } = setupCompute({ - workdir: sub, - explicitWorkdir: false, + workdir: resolved.workdir, + explicitWorkdir: resolved.explicit, routes: { [listRoute]: { status: 200, body: { data: [] } } }, }); diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index babc1b631c..42010112ba 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, this command has no discovery or side effects. @@ -106,14 +106,14 @@ guaranteed and is deduplicated on the Logflare-minted `id`. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md index e6b43b8be7..ef89a6e823 100644 --- a/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/new/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, it is absent from help and completion; direct invocation follows the normal unknown-command path and the command handler does not run. See the [Compute command guide](../../../../../docs/compute-commands.md). @@ -122,12 +122,12 @@ root. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/new/new.command.ts b/apps/cli/src/commands/experimental/compute/new/new.command.ts index e643e3977c..c4861e2c71 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -1,6 +1,8 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { @@ -105,4 +107,6 @@ export const computeNewCommand = Command.make("new", config).pipe( computeNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(computeNewRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/compute/new/new.handler.ts b/apps/cli/src/commands/experimental/compute/new/new.handler.ts index e08d23720a..8ca807ee72 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.handler.ts @@ -4,6 +4,7 @@ import { emitSuccessTrailer } from "../../../../shared/cli/success-trailer.ts"; import { aqua, bold } from "../../../../command-internal/colors.ts"; import { validateWorkdirIsDirectory } from "../../../../command-internal/workdir-validation.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { CliConfigValues } from "../../../../config/cli-config-values.service.ts"; import { renderComputeDetails } from "../compute.format.ts"; import { emitComputeMachineOutput, computeMachineOutputRequested } from "../compute.output.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; @@ -239,6 +240,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF const telemetryState = yield* TelemetryState; const runtimeInfo = yield* RuntimeInfo; const cliSettings = yield* CommandSettings; + const configValues = yield* CliConfigValues; // The telemetry state file is written on every invocation, success or failure. yield* Effect.gen(function* () { @@ -364,7 +366,7 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF yield* fs.writeFileString(path.join(destination, filename), contents); } - yield* commitComputeEntry(configWrite); + yield* configValues.writeThrough(commitComputeEntry(configWrite)); }).pipe(Effect.onError(() => removeScaffold)); // Relative to the project root when the workdir was defaulted, since it also diff --git a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts index 19613e5800..9fb681fc49 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.integration.test.ts @@ -18,6 +18,7 @@ import { COMPUTE_RUNTIME_EXCLUSIONS, type ComputeRuntime, } from "../../../../shared/compute/compute-runtimes.ts"; +import { resolveWorkdir } from "../../../../config/command-settings.layer.ts"; import { COMPUTE_STACKS } from "../../../../shared/compute/compute-stacks.ts"; import { computeNew } from "./new.handler.ts"; import { ComputeNewWorkdirError } from "./new.errors.ts"; @@ -495,7 +496,11 @@ describe("compute new", () => { }); const sub = path.join(created.dir, "nested", "dir"); yield* fs.makeDirectory(sub, { recursive: true }); - const { layer } = setupCompute({ workdir: sub, explicitWorkdir: false }); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), sub, path); + const { layer } = setupCompute({ + workdir: resolved.workdir, + explicitWorkdir: resolved.explicit, + }); return yield* Effect.gen(function* () { const error = yield* computeNew( @@ -947,7 +952,11 @@ describe("compute new", () => { const repo = yield* makeComputeProject({ "supabase/config.json": configJson }); const child = path.join(repo.dir, "nested"); yield* fs.makeDirectory(child); - const { layer, out } = setupCompute({ workdir: child, explicitWorkdir: false }); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), child, path); + const { layer, out } = setupCompute({ + workdir: resolved.workdir, + explicitWorkdir: resolved.explicit, + }); return yield* Effect.gen(function* () { const error = yield* computeNew(flags({ name: Option.none() })).pipe(Effect.flip); expect(error).toBeInstanceOf(ComputeJsonConfigUnsupportedError); diff --git a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md index 67f60895be..a159638851 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, it is absent from help and completion; direct invocation follows the normal unknown-command path and the command handler does not run. See the [Compute command guide](../../../../../docs/compute-commands.md). @@ -66,13 +66,13 @@ run reports the accepted spec the deploy response returned. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/push/push.command.ts b/apps/cli/src/commands/experimental/compute/push/push.command.ts index a2d833d080..0ca6246a48 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.command.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.command.ts @@ -1,5 +1,7 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { COMPUTE_EXPOSURES } from "../../../../shared/compute/compute-runtimes.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; @@ -84,4 +86,6 @@ export const computePushCommand = Command.make("push", config).pipe( computePush(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["compute", "push"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index 9c420b5695..b1d23e261f 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -4,7 +4,7 @@ This command is registered only when `experimental.compute` is enabled. Set `SUPABASE_EXPERIMENTAL_COMPUTE=1` to enable it, or `0` to disable it; an unset -or empty variable uses the project configuration. Any other non-empty value +or empty variable uses the project configuration. Any non-boolean value reports an invalid feature-flag value before command parsing. When disabled, it is absent from help and completion; direct invocation follows the normal unknown-command path and the command handler does not run. See the [Compute command guide](../../../../../docs/compute-commands.md). @@ -51,14 +51,14 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/status/status.command.ts b/apps/cli/src/commands/experimental/compute/status/status.command.ts index 692669787a..4af2c4b9cd 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.command.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.command.ts @@ -1,5 +1,7 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -30,4 +32,6 @@ export const computeStatusCommand = Command.make("status", config).pipe( computeStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["compute", "status"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 8fc1c5d29b..9e3fc54c12 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -4,6 +4,8 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackPrepare } from "./prepare.handler.ts"; import { STACK_PREPARABLE_CAPABILITIES } from "../start/start.options.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; const config = { stack: Flag.String("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), @@ -40,4 +42,6 @@ export const stackPrepareCommand = Command.make("prepare", config).pipe( Command.withHandler((flags) => stackPrepare(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index 4421d325b6..9e40c1135d 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -28,6 +28,7 @@ import { StackApi, StackTargetResolver } from "../stack.shared.ts"; import { stackPrepare } from "./prepare.handler.ts"; import type { StackPrepareFlags } from "./prepare.command.ts"; import { StackCommandPrepareError } from "./prepare.errors.ts"; +import { cliConfigValuesTestLayer } from "../../../../../tests/helpers/config-values-layer.ts"; const id = "a".repeat(64); const flags = (overrides: Partial = {}): StackPrepareFlags => ({ @@ -169,6 +170,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => { const output = mockOutput(); const telemetry = mockTelemetryStateTracked(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, options.runtimeInfo ?? runtimeInfoLayer, engines.layer, diff --git a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts index 7c82c1835c..e44cb70191 100644 --- a/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-backend.integration.test.ts @@ -8,9 +8,14 @@ import { StackRoutingError, resolveStackBackend, } from "../../../command-internal/stack-backend.ts"; +import { withConfigEnv } from "../../../../tests/helpers/command-mocks.ts"; -const resolve = (input: Parameters[0]) => - resolveStackBackend(input).pipe(Effect.provide(BunServices.layer)); +const resolve = ({ + env, + ...input +}: Parameters[0] & { + readonly env: Readonly>; +}) => withConfigEnv(env, resolveStackBackend(input)).pipe(Effect.provide(BunServices.layer)); const project = (config: string, format: "toml" | "json" = "toml") => Effect.gen(function* () { @@ -196,7 +201,7 @@ stack = true expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { expect(error.value).toBeInstanceOf(StackRoutingError); - expect(String(error.value)).toContain("0 or 1"); + expect(String(error.value)).toContain("expected true or false"); } } expect( diff --git a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts index fbe22e26eb..1cf9a7bd27 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts @@ -8,6 +8,7 @@ import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts" import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { loadStackConfig } from "../../../command-internal/stack-config.ts"; import { createStackConfigProject } from "../../../../tests/helpers/stack-config.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; const project = ( config: string, @@ -20,7 +21,7 @@ const project = ( const load = (projectRoot: string) => loadStackConfig(projectRoot).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer, cliConfigValuesTestLayer)), ); const withEnvironment = ( diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 79421ca9a2..4a0b64ad0b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -15,15 +15,16 @@ import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, } from "../../../telemetry/command-telemetry-attributes.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; const load = (projectRoot: string) => loadStackConfig(projectRoot).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer, cliConfigValuesTestLayer)), ); const project = (contents: string, options: Parameters[1] = {}) => createStackConfigProject(contents, options).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer, cliConfigValuesTestLayer)), ); const publicJwkSchema = Schema.Struct({ diff --git a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts index d48745aeff..d447554b53 100644 --- a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts @@ -11,8 +11,9 @@ import { makeSpec as studioSpec } from "../../../../../../packages/stack/src/ser import { loadStackConfig } from "../../../command-internal/stack-config.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; import { createStackConfigProject } from "../../../../tests/helpers/stack-config.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -const layer = Layer.merge(BunServices.layer, runtimeInfoLayer); +const layer = Layer.mergeAll(BunServices.layer, runtimeInfoLayer, cliConfigValuesTestLayer); describe("stack service configuration", () => { it.live("carries editable TOML settings into native and container service environments", () => @@ -180,6 +181,40 @@ jwt_secret = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61 }).pipe(Effect.provide(layer)), ); + it.live("decrypts edge runtime secrets before forwarding them to the functions service", () => + Effect.gen(function* () { + const root = yield* createStackConfigProject( + `project_id = "encrypted-functions-env" +[edge_runtime.secrets] +SHARED = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61WWhEf3+8SEDngaEMZnSWajCMCInbHJbRnH+C1xgcAZlWKR0qLcHanvkM+zDKWxcQgMbN5AmOqwn3olCjpHbqkSzoyPri015szpcZMp5JKGmUsw6KEwTFE7LyQwRlTbqlVn7u" +PLAIN = "plain-value" +`, + { + rootEnv: + "DOTENV_PRIVATE_KEY=7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb\n", + }, + ); + const config = yield* loadStackConfig(root); + const services = yield* config.creations("encrypted-functions-env"); + expect(services.find((service) => service.service === "functions")?.config.env).toEqual({ + SHARED: "test-jwt-secret-with-more-than-32-characters", + PLAIN: "plain-value", + }); + }).pipe(Effect.provide(layer)), + ); + + it.live("fails the stack config when an edge runtime secret cannot be decrypted", () => + Effect.gen(function* () { + const root = yield* createStackConfigProject(`project_id = "undecryptable-functions-env" +[edge_runtime.secrets] +SHARED = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61WWhEf3+8SEDngaEMZnSWajCMCInbHJbRnH+C1xgcAZlWKR0qLcHanvkM+zDKWxcQgMbN5AmOqwn3olCjpHbqkSzoyPri015szpcZMp5JKGmUsw6KEwTFE7LyQwRlTbqlVn7u" +`); + const error = yield* loadStackConfig(root).pipe(Effect.flip); + expect(error._tag).toBe("StackConfigError"); + expect(error.message).toBe("failed to parse config: missing private key"); + }).pipe(Effect.provide(layer)), + ); + it.live("leaves empty configured secrets for the stack to normalize", () => Effect.gen(function* () { const root = yield* createStackConfigProject(`project_id = "empty-secrets" diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 17f94aceec..e7da40e0a0 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -24,7 +24,8 @@ S3 protocol/vector controls are forwarded to their services. Storage receives th keys and region, and uses the gateway's `/storage/v1` prefix to verify S3 signatures and to build resumable upload URLs. `analytics.vector_port` and `SUPABASE_ANALYTICS_VECTOR_PORT` are accepted and ignored: the stack runs no Vector service. -Encrypted JWT secrets are decrypted before shared credentials are derived. `db.health_timeout` +Encrypted JWT secrets are decrypted before shared credentials are derived, and encrypted +`[edge_runtime.secrets]` values are decrypted before they reach the Functions service. `db.health_timeout` controls database readiness; package JWT and PostgreSQL root-key defaults apply when omitted, and the effective root key is supplied through a stack-owned key file. Studio receives the database connection, the Functions management directory/URL, and Analytics diff --git a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts index 04c94d1c88..b8447875fc 100644 --- a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts @@ -201,7 +201,6 @@ const runStatusEnv = (input: { poolerHost: "supabase.com", dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), - dbPassword: Option.none(), githubToken: Option.none(), projectId: Option.none(), workdir, diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index c7feb0eca0..9b06c82fd1 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -8,6 +8,8 @@ import { stringSliceFlag } from "../../../../command-internal/string-slice-flag. import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { stackStart } from "./start.handler.ts"; import { STACK_START_EXCLUDABLE_CAPABILITIES } from "./start.options.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; const excludeFlag = stringSliceFlag( "exclude", @@ -71,4 +73,6 @@ export const stackStartCommand = Command.make("start", config).pipe( // `machineErrorContextLayer` carries the structured incompatible-change payload onto the // JSON/stream-json error envelope. Command.provide(Layer.mergeAll(stdinLayer, machineErrorContextLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 2b3d903de7..e3057b36af 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -57,15 +57,18 @@ import { SeedConfigLoadError, seedBucketsRun, } from "../../../../command-internal/seed-buckets.ts"; -import { loadLocalProjectContext } from "../../../../command-internal/local-project-context.ts"; +import { + describeConfigLoadFailure, + loadResolvedConfigContext, +} from "../../../../command-internal/resolved-config-context.ts"; +import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; +import type { CliConfigMaterialized } from "../../../../config/cli-config-values.service.ts"; import { loadStackConfig, - stackEndpointSetting, - stackMajorVersionSetting, - stackOrioledbVersionSetting, - type StackEndpointSetting, + stackEndpointKey, + stackMajorVersionKey, + type StackEndpointKey, } from "../../../../command-internal/stack-config.ts"; -import { envOverride } from "../../../../command-internal/local-config-values.ts"; import { StackApi, stackCapabilityForService, @@ -116,18 +119,18 @@ const portConflictSuggestion = ( portSaved = false, ): string | undefined => { if (conflict === undefined) return undefined; - const settings = new Map(); + const settings = new Map(); for (const { service, endpoints } of requested) { if (!isRecord(endpoints)) continue; for (const [name, intent] of Object.entries(endpoints)) { - const setting = stackEndpointSetting(service, name); + const setting = stackEndpointKey(service, name); if (setting !== undefined && isRecord(intent) && intent.port === conflict.port) - settings.set(setting.envVar, setting); + settings.set(setting.path, setting); } } const [setting, ...rest] = settings.values(); if (setting === undefined || rest.length > 0) return undefined; - const choice = `Set \`${setting.configPath}\` in supabase/config.toml (or ${setting.envVar}) to a free port.`; + const choice = `Set \`${setting.path}\` in supabase/config.toml (or ${setting.env[0]}) to a free port.`; return portSaved ? `${choice} The stack already saved this port (any previous automatic port is released), so the next start uses it once it is free.` : choice; @@ -258,12 +261,14 @@ const formatConfigPath = (path: string): string => { /** The display key for a setting: its env var when that's what overrides it, else its config key. */ const settingKeyLabel = ( - setting: StackEndpointSetting, - projectEnvValues: Readonly>, -): string => - envOverride(setting.envVar, undefined, projectEnvValues) !== undefined - ? setting.envVar - : formatConfigPath(setting.configPath); + setting: StackEndpointKey, + originAt: CliConfigMaterialized["originAt"], +): string => { + const { tier } = originAt(setting.path); + return tier === "shell" || tier === "projectEnv" + ? (setting.env[0] ?? formatConfigPath(setting.path)) + : formatConfigPath(setting.path); +}; /** * One setting behind an incompatible path, reported as the JSON/stream-json error envelope's @@ -285,7 +290,7 @@ const describeSettingChange = ( path: string, savedCreation: ServiceCreation | undefined, requestedCreation: ServiceCreationInput | undefined, - projectEnvValues: Readonly>, + originAt: CliConfigMaterialized["originAt"], ): StructuredSettingChange | ReadonlyArray => { if (service === "database" && path === "config.version") { // `postgresVersion` resolves a bare major alias (e.g. "17") to the pinned build the @@ -312,7 +317,7 @@ const describeSettingChange = ( changes.push({ service, path, - key: settingKeyLabel(stackMajorVersionSetting, projectEnvValues), + key: settingKeyLabel(stackMajorVersionKey, originAt), saved: savedMajor, requested: requestedMajor, editable: true, @@ -321,7 +326,7 @@ const describeSettingChange = ( changes.push({ service, path, - key: settingKeyLabel(stackOrioledbVersionSetting, projectEnvValues), + key: settingKeyLabel(CliConfigKeys.db.orioledbVersion, originAt), saved: savedOrioledb, requested: requestedOrioledb, // A catalog upgrade can retire the saved build, which the config then rejects. @@ -330,13 +335,12 @@ const describeSettingChange = ( return changes; } const endpointName = path.startsWith("endpoints.") ? path.split(".")[1] : undefined; - const setting = - endpointName === undefined ? undefined : stackEndpointSetting(service, endpointName); + const setting = endpointName === undefined ? undefined : stackEndpointKey(service, endpointName); if (endpointName !== undefined && setting !== undefined) return { service, path, - key: settingKeyLabel(setting, projectEnvValues), + key: settingKeyLabel(setting, originAt), saved: endpointPortLabel(savedCreation?.endpoints, endpointName), requested: endpointPortLabel(requestedCreation?.endpoints, endpointName), editable: true, @@ -358,7 +362,7 @@ const incompatibleSettingChanges = ( planned: ReadonlyArray, savedConfigById: ReadonlyMap, requested: ReadonlyArray, - projectEnvValues: Readonly>, + originAt: CliConfigMaterialized["originAt"], ): ReadonlyArray => planned .filter((entry) => entry.member && entry.change === "incompatible") @@ -371,7 +375,7 @@ const incompatibleSettingChanges = ( path, savedConfigById.get(entry.id), requested.find((creation) => creation.service === entry.service), - projectEnvValues, + originAt, ), ) : [], @@ -415,7 +419,7 @@ const incompatibleChange = ( planned: ReadonlyArray, savedConfigById: ReadonlyMap, requested: ReadonlyArray, - projectEnvValues: Readonly>, + originAt: CliConfigMaterialized["originAt"], stackIdentity: { readonly id: string; readonly name?: string }, ownerLive: boolean, ): @@ -426,7 +430,7 @@ const incompatibleChange = ( readonly command: string | undefined; } | undefined => { - const changes = incompatibleSettingChanges(planned, savedConfigById, requested, projectEnvValues); + const changes = incompatibleSettingChanges(planned, savedConfigById, requested, originAt); if (changes.length === 0) return undefined; const lines = settingChangeLines(changes); const command = destroyCommandFor(stackIdentity.id); @@ -883,7 +887,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags planned, savedConfigById, requested, - config.projectEnvValues, + config.originAt, stackIdentity, target.hostRunning, ); @@ -1032,9 +1036,10 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags if (initialComposition) { const storage = members.find((instance) => instance.service === "storage"); if (storage !== undefined) { - const context = yield* loadLocalProjectContext( - target.projectRoot, - (message) => new SeedConfigLoadError({ message }), + const context = yield* loadResolvedConfigContext(target.projectRoot).pipe( + Effect.mapError( + (cause) => new SeedConfigLoadError({ message: describeConfigLoadFailure(cause) }), + ), ); if (hasConfiguredBuckets(context.config)) { yield* Effect.annotateCurrentSpan({ "stack.storage_seeded": true }); @@ -1058,7 +1063,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags // Non-interactive prompts here would fake a `[Y/n]` question nobody answers. promptless: true, credentials, - resolvedConfig: { config: context.config, document: context.loaded?.document }, + resolvedConfig: { config: context.config, document: context.document }, projectEnvValues: toml.projectEnv, workdir: target.projectRoot, }).pipe(Effect.mapError(stackError)); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index e0b065eadd..01e30b6f61 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -62,6 +62,7 @@ import { jsonOutputLayer, streamJsonOutputLayer } from "../../../../shared/outpu import { StackApi, stackApiLayer, StackTargetResolver } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; import { StackCommandStartError } from "./start.errors.ts"; +import { cliConfigValuesTestLayer } from "../../../../../tests/helpers/config-values-layer.ts"; const flags = (exclude: ReadonlyArray = []) => ({ exclude, @@ -559,6 +560,7 @@ const layers = ( findDeleted: () => Effect.die("identity not used"), }); return Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, runtimeInfo, output.layer, diff --git a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts index 76e1810448..6ac1016838 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts @@ -22,6 +22,7 @@ import { ExperimentalFlag, YesFlag } from "../../../../command-internal/global-f import { stackStart } from "./start.handler.ts"; import { stackPrepare } from "../prepare/prepare.handler.ts"; import { destroyTestStacks } from "../../../../../tests/helpers/stack-cleanup.ts"; +import { cliConfigValuesTestLayer } from "../../../../../tests/helpers/config-values-layer.ts"; const excluded = [ "rest", @@ -196,6 +197,7 @@ const makeLayers = (root: string, apiLayer = liveStackApi, workdir = root) => { output, telemetry, layer: Layer.mergeAll( + cliConfigValuesTestLayer, FetchHttpClient.layer, runtimeInfoLayer, settings, @@ -508,12 +510,14 @@ describe("experimental stack start native lifecycle", () => { expect(yield* stack.services.list).toHaveLength(0); expect((yield* stack.composition.describe).members).toHaveLength(0); } - // A stopped stack now reads its project config before reopening the stack, to carry a - // changed endpoint into the owner's own startup; an invalid config rejects here without - // spawning another owner, leaving `ownerPids[2]` unset. + // A stopped stack reads its project config before reopening, so an invalid config + // rejects without spawning another owner (`ownerPids[2]` stays unset). The resolved + // config is memoised per runtime, so the rewritten file needs a fresh service. yield* fs.writeFileString(path.join(root, "supabase", "config.toml"), "project_id = ["); activeAttempt = 2; - const invalidConfigStart = yield* Effect.scoped(Effect.exit(stackStart(flags([])))); + const invalidConfigStart = yield* Effect.scoped( + Effect.exit(stackStart(flags([]))), + ).pipe(Effect.provide(cliConfigValuesTestLayer)); expect(Exit.isFailure(invalidConfigStart)).toBe(true); if (!Exit.isFailure(invalidConfigStart)) return; const configError = Cause.findErrorOption(invalidConfigStart.cause); @@ -638,11 +642,14 @@ describe("experimental stack start native lifecycle", () => { yield* stack.stop; // The config drops the fixed API port and now excludes Auth too: REST alone remains, // so Auth's still-saved fixed port must not anchor REST's shared port back to it. + // The resolved config is memoised per runtime, so the rewritten file needs a fresh service. yield* fs.writeFileString( path.join(root, "supabase", "config.toml"), n2ProjectConfig(undefined), ); - const restartedId = yield* stackStart(flags(onlyRest)); + const restartedId = yield* stackStart(flags(onlyRest)).pipe( + Effect.provide(cliConfigValuesTestLayer), + ); expect(restartedId).toBe(stackId); const restarted = yield* api.open({ id: restartedId, ...locations(root) }); const restAfter = (yield* restarted.services.list).find( diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index 734cfa0dab..8dafade58e 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -4,6 +4,8 @@ import { stringSliceFlag } from "../../../../command-internal/string-slice-flag. import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackStatus } from "./status.handler.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; const config = { stack: Flag.String("stack").pipe(Flag.withDescription("Inspect a named stack."), Flag.optional), @@ -43,4 +45,6 @@ export const stackStatusCommand = Command.make("status", config).pipe( Command.withHandler((flags) => stackStatus(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 516dc0d792..8d53b0b21b 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -18,6 +18,7 @@ import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer. import { StackApi, StackTargetResolver } from "../stack.shared.ts"; import type { StackStatusFlags } from "./status.command.ts"; import { stackStatus } from "./status.handler.ts"; +import { cliConfigValuesTestLayer } from "../../../../../tests/helpers/config-values-layer.ts"; const stackId = "a".repeat(64); type StatusOutputFormat = "text" | "json" | "stream-json"; @@ -264,6 +265,7 @@ const runStatus = (input: { out.layer, telemetry.layer, mockCommandSettings({ workdir: projectRoot, supabaseHome: root }), + cliConfigValuesTestLayer, BunServices.layer, runtimeInfoLayer, ); diff --git a/apps/cli/src/commands/feedback/feedback-project-ref.ts b/apps/cli/src/commands/feedback/feedback-project-ref.ts index 2f5710e2bc..02ef3dcdea 100644 --- a/apps/cli/src/commands/feedback/feedback-project-ref.ts +++ b/apps/cli/src/commands/feedback/feedback-project-ref.ts @@ -4,7 +4,7 @@ import { INVALID_PROJECT_REF_MESSAGE, PROJECT_REF_PATTERN, } from "../../config/project-ref.service.ts"; -import { readProjectRefFile } from "../../command-internal/temp-paths.ts"; +import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; // Mirrors `ProjectRefResolver` (`project-ref.layer.ts`) source by source: // diff --git a/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts b/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts index b2bf2d0a46..c76d6c08f4 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.integration.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { feedbackClientLayer } from "../../shared/feedback/feedback-client.layer.ts"; import { FeedbackClient } from "../../shared/feedback/feedback-client.service.ts"; -import type { DebugLoggerShape } from "../../command-internal/debug-logger.service.ts"; +import type { DebugLoggerShape } from "../../shared/output/debug-logger.service.ts"; import { feedbackFetch } from "./feedback.layers.ts"; // The environment only needs a url/key shape — no request leaves the test. diff --git a/apps/cli/src/commands/feedback/feedback.layers.ts b/apps/cli/src/commands/feedback/feedback.layers.ts index 2125cfe30a..aad45bec57 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.ts @@ -7,8 +7,8 @@ import { DnsResolverFlag } from "../../command-internal/global-flags.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; -import type { DebugLoggerShape } from "../../command-internal/debug-logger.service.ts"; -import { DebugLogger } from "../../command-internal/debug-logger.service.ts"; +import type { DebugLoggerShape } from "../../shared/output/debug-logger.service.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { dohFetch, type DohFetchOptions } from "../../command-internal/http-dns.ts"; export const feedbackCliConfigLayer = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts index 1d18948076..6ad572d2e1 100644 --- a/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts +++ b/apps/cli/src/commands/feedback/feedback.layers.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import type { DebugLoggerShape } from "../../command-internal/debug-logger.service.ts"; +import type { DebugLoggerShape } from "../../shared/output/debug-logger.service.ts"; import { feedbackFetch } from "./feedback.layers.ts"; function recordingLogger() { diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 5ca78284ff..58df33833f 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -27,14 +27,14 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` or `supabase/config.json` -> cwd) | ## Exit Codes diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 6748317a00..a28e9fe980 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -2,18 +2,18 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`resolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | -| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `localConfigLoader`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past `` — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | -| `/supabase/functions//index.ts` | TypeScript | function source to deploy | -| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | -| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | -| configured static files | any | when `static_files` patterns match local files | -| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | -| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`loadCliProjectEnvFiles`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.json` or `config.toml` | JSON / TOML | `config.json` preferred when both exist; to resolve function config, project id, and local Functions — through `CliConfigValues` (same resolver `start`/`stop`/`status` use; flag, shell, project dotenv, then config), so no ancestor directory is searched past `` and the Docker project id is `project_id` or, when unset, the workdir name — this now (CLI-2285) applies to the functions manifest inference as well, so the two loads can never disagree about which project they resolve; also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/functions//index.ts` | TypeScript | function source to deploy | +| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | +| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | +| configured static files | any | when `static_files` patterns match local files | +| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | +| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | ## Files Written @@ -90,6 +90,9 @@ The `--output`/`-o` flag does not change deploy output. ## Notes +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`) > default. The whole config is decoded up front, so an + invalid value, including an unparsable `SUPABASE_*` override, fails the command. - If no function name is provided, deploys all functions found in `supabase/functions/`. - API-based deploys anchor uploaded file names and the recorded `entrypoint_path` / `import_map_path` / `static_patterns` at the workdir (relative to the working @@ -102,9 +105,9 @@ The `--output`/`-o` flag does not change deploy output. - `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes. - `--prune` deletes deployed Functions that are not present locally after a confirmation prompt; global `--yes` skips the prompt. -- **Spec-strict import-map key matching (CLI-2179):** the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, +- **Spec-strict import-map key matching:** the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions serve` and `start`'s Edge Runtime bring-up) matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only - for a `/`-suffixed key — rather than any-key prefix matching, so maps that relied - on bare-key prefix matching upload fewer files; an unwalkable target (`ENOTDIR` — a value routed through a + for a `/`-suffixed key — so a bare-key prefix does not match, and upload sets are + limited to spec-matching keys; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN` instead of aborting the deploy. diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 1871002573..67c6ffd1fb 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,6 +1,8 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -78,4 +80,6 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( // `stdinLayer`: the `--prune` confirmation reads piped stdin via `promptYesNo` // on a non-TTY stdin. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["functions", "deploy"]), stdinLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index cab1d93d37..a7207deb45 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -28,6 +28,7 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; @@ -154,6 +155,7 @@ describe("functions deploy", () => { const linkedProjectCache = mockLinkedProjectCacheTracked(); const telemetry = mockTelemetryStateTracked(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -218,6 +220,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -275,6 +278,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -339,6 +343,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -400,6 +405,7 @@ describe("functions deploy", () => { ), }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -472,6 +478,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -522,6 +529,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -598,6 +606,7 @@ describe("functions deploy", () => { const brokenPipe = new Error("EPIPE: broken pipe, write"); const api = mockCommandPlatformApi({ handler: deployedHelloWorld }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out: { ...out, @@ -644,6 +653,7 @@ describe("functions deploy", () => { const gate = Deferred.makeUnsafe(); const continued = Deferred.makeUnsafe(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out: { ...out, @@ -760,6 +770,7 @@ describe("functions deploy", () => { }); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -816,6 +827,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api: mockCommandPlatformApi({ handler: deployedHelloWorld }), @@ -834,7 +846,11 @@ describe("functions deploy", () => { const path = yield* Path.Path; yield* writeCliConfig(tempRoot.current); yield* writeLocalFunction(tempRoot.current, "hello-world"); - yield* fs.writeFileString(path.join(tempRoot.current, "supabase", ".temp"), ""); + // A dangling `.temp` link reads as unlinked yet cannot be created. + yield* fs.symlink( + path.join(tempRoot.current, "missing-target"), + path.join(tempRoot.current, "supabase", ".temp"), + ); const error = yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }).pipe( Effect.flip, @@ -873,6 +889,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -968,6 +985,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1055,6 +1073,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1154,6 +1173,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1187,6 +1207,7 @@ describe("functions deploy", () => { handler: (request) => Effect.succeed(jsonResponse(request, 200, [])), }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1263,6 +1284,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1316,6 +1338,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1374,6 +1397,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1423,6 +1447,7 @@ describe("functions deploy", () => { const out = mockOutput({ format }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1495,6 +1520,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1526,6 +1552,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1560,6 +1587,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1590,6 +1618,7 @@ describe("functions deploy", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1648,6 +1677,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1684,6 +1714,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1716,6 +1747,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1751,6 +1783,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1789,6 +1822,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1833,6 +1867,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1887,6 +1922,7 @@ describe("functions deploy", () => { const api = mockFunctionCreateApi(); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1908,10 +1944,10 @@ describe("functions deploy", () => { expect(child.spawned[2]).toEqual({ command: "docker", - args: ["network", "inspect", "supabase_network_abcdefghijklmnopqrst"], + args: ["network", "inspect", "supabase_network_nested"], }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).toContain("supabase_network_abcdefghijklmnopqrst"); + expect(runCommand?.args).toContain("supabase_network_nested"); expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }, @@ -1948,6 +1984,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2013,6 +2050,7 @@ describe("functions deploy", () => { }); const child = mockChildProcessSpawner({ exitCode: 1 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index b76852a0a3..c55ca3de14 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -2,17 +2,17 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`resolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | -| `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadCliConfig`) for the Docker-unbundle path. The local config loader sets `tomlOnly: true`, so `config.json` is never read here, unlike other `loadCliConfig` callers. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`loadCliProjectEnvFiles`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.json` or `config.toml` | JSON / TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` for the Docker-unbundle path. The effective values come from `CliConfigValues` (flag, shell, project dotenv, then config), so `config.json` is read like every other command; the Docker project id is `project_id` or, when unset, the workdir name. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -48,20 +48,20 @@ in text mode, to stderr in machine-output modes (CLI-1546). ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | -| `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`getRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | -| `SUPABASE_USE_SLIM_IMAGES` | Docker-unbundle path: resolves the edge-runtime unbundle image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | -| `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| Variable | Purpose | Required? | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` or `supabase/config.json` -> cwd) | +| `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`getRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| `SUPABASE_USE_SLIM_IMAGES` | Docker-unbundle path: resolves the edge-runtime unbundle image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | +| `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | ## Exit Codes @@ -102,6 +102,9 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`) > default. The whole config is decoded up front, so an + invalid value, including an unparsable `SUPABASE_*` override, fails the command. - If no function name is provided, downloads all functions. - Requires a linked project (`--project-ref` or linked project config). - The `--use-api` path rejects path traversal and symlink escapes before writing source files diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index be17e55266..3f5c043f21 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -1,6 +1,8 @@ import { Effect, Layer, Option } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -85,4 +87,6 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( ? functionsDownloadLegacyBundleHandler(flags) : functionsDownloadHandler(flags).pipe(Effect.provide(managementApiLayer)), ), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 39bfc34cd0..1ee742ff55 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -35,8 +35,10 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; +import { sanitizeProjectId } from "../../../shared/config/project-id.ts"; import { containerRuntimeNotFoundMessage } from "../../../command-internal/container-cli.ts"; import { downloadFunctions } from "../../../shared/functions/download.ts"; import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; @@ -130,6 +132,11 @@ function mockDockerRunSpawnFailure() { const tempRoot = useTempWorkdir("supabase-functions-download-legacy-"); +const workdirProjectId = Effect.gen(function* () { + const path = yield* Path.Path; + return sanitizeProjectId(path.basename(tempRoot.current)); +}); + // `withCommandTelemetry` threads `flags`/`command` through // `CurrentAnalyticsContext`, not `capture()`'s own args, so this merges it manually. function mockContextualAnalytics() { @@ -199,6 +206,7 @@ describe("functions download", () => { const linkedProjectCache = mockLinkedProjectCacheTracked(); const telemetry = mockTelemetryStateTracked(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -246,6 +254,7 @@ describe("functions download", () => { ], ) { return Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out: mockOutput({ format: "text" }), api: mockCommandPlatformApi({ handler }), @@ -365,7 +374,11 @@ describe("functions download", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(path.join(tempRoot.current, "supabase"), { recursive: true }); - yield* fs.writeFileString(path.join(tempRoot.current, "supabase", ".temp"), ""); + // A dangling `.temp` link reads as unlinked yet cannot be created. + yield* fs.symlink( + path.join(tempRoot.current, "missing-target"), + path.join(tempRoot.current, "supabase", ".temp"), + ); const error = yield* functionsDownload({ ...baseFlags, useDocker: true }).pipe(Effect.flip); @@ -436,6 +449,7 @@ describe("functions download", () => { : Effect.void, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api: mockCommandPlatformApi({ @@ -475,6 +489,7 @@ describe("functions download", () => { runStderr: ["unbundle: warning about deno.json"], }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -521,6 +536,7 @@ describe("functions download", () => { : Effect.succeed(jsonResponse(request, 200, {})), }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -561,6 +577,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -602,6 +619,7 @@ describe("functions download", () => { // routes it to stderr, keeping stdout payload-only. const child = mockDockerUnbundle({ runStdout: ["unbundle: wrote index.ts"] }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -653,6 +671,7 @@ describe("functions download", () => { }); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -710,6 +729,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -736,7 +756,7 @@ describe("functions download", () => { // own Docker-bundling path. expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", - args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + args: ["network", "inspect", `supabase_network_${yield* workdirProjectId}`], }); expect(child.spawned.find((spawned) => spawned.args[0] === "volume")).toEqual({ command: "docker", @@ -744,10 +764,10 @@ describe("functions download", () => { "volume", "create", "--label", - `com.supabase.cli.project=${PROJECT_ID}`, + `com.supabase.cli.project=${yield* workdirProjectId}`, "--label", - `com.docker.compose.project=${PROJECT_ID}`, - `supabase_edge_runtime_${PROJECT_ID}`, + `com.docker.compose.project=${yield* workdirProjectId}`, + `supabase_edge_runtime_${yield* workdirProjectId}`, ], }); @@ -760,14 +780,14 @@ describe("functions download", () => { ); const functionsDir = path.resolve(tempRoot.current, "supabase", "functions"); expect(runCommand?.args).toContain( - `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, + `supabase_edge_runtime_${yield* workdirProjectId}:/root/.cache/deno:rw`, ); expect(runCommand?.args).toContain( `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, ); expect(runCommand?.args).toContain(`${functionsDir}:/home/deno:rw`); expect(runCommand?.args).toContain("--network"); - expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).toContain(`supabase_network_${yield* workdirProjectId}`); // The unbundle tail is always the last 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ @@ -790,6 +810,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -808,31 +829,28 @@ describe("functions download", () => { }), ); - return Effect.gen(function* () { - const path = yield* Path.Path; - yield* functionsDownload({ ...baseFlags, useDocker: true }); + return withConfigEnv( + { BITBUCKET_CLONE_DIR: "" }, + Effect.gen(function* () { + const path = yield* Path.Path; + yield* functionsDownload({ ...baseFlags, useDocker: true }); - const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).not.toContain( - `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, - ); - const hostEszipPath = path.resolve( - tempRoot.current, - "supabase", - ".temp", - "output_hello-world.eszip", - ); - expect(runCommand?.args).toContain( - `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, - ); - expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); - }).pipe( - Effect.provide(layer), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ BITBUCKET_CLONE_DIR: "" }, { preserveEmptyStrings: true }), - ), - ); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).not.toContain( + `supabase_edge_runtime_${yield* workdirProjectId}:/root/.cache/deno:rw`, + ); + const hostEszipPath = path.resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, + ); + expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); + }), + ).pipe(Effect.provide(layer)); }); it.live("requests the raw eszip body instead of a negotiated JSON response", () => { @@ -843,6 +861,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -874,6 +893,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -906,7 +926,7 @@ describe("functions download", () => { }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("custom-network"); - expect(runCommand?.args).not.toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).not.toContain(`supabase_network_${yield* workdirProjectId}`); }).pipe(Effect.provide(layer)); }); @@ -917,6 +937,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -941,10 +962,10 @@ describe("functions download", () => { expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", - args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + args: ["network", "inspect", `supabase_network_${yield* workdirProjectId}`], }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).toContain(`supabase_network_${yield* workdirProjectId}`); }).pipe(Effect.provide(layer)); }, ); @@ -954,6 +975,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -992,6 +1014,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1017,7 +1040,7 @@ describe("functions download", () => { yield* functionsDownload({ ...baseFlags, useDocker: true }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).toContain(`supabase_network_${yield* workdirProjectId}`); expect(runCommand?.args).not.toContain("custom-network"); }).pipe(Effect.provide(layer)); }, @@ -1026,14 +1049,14 @@ describe("functions download", () => { it.live( "does not climb to an ancestor project's config.toml for the Docker download path", () => { - // No ancestor climb: `resolveEdgeRuntimeImage` uses `search: false`, so - // a nested workdir without its own config.toml falls back to - // `--project-ref` rather than an ancestor's `project_id`. + // A nested workdir without its own config.toml is named after the workdir, + // not an ancestor's `project_id` and not `--project-ref`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const nestedWorkdir = `${tempRoot.current}/nested`; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1066,22 +1089,21 @@ describe("functions download", () => { expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", - args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + args: ["network", "inspect", "supabase_network_nested"], }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).toContain("supabase_network_nested"); expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); }).pipe(Effect.provide(layer)); }, ); - it.live("prefers config.toml over a stray config.json for the Docker download path", () => { - // Uses `resolveEdgeRuntimeImage`'s `tomlOnly: true`, so a workdir with - // both files resolves `project_id` from config.toml, not config.json. + it.live("prefers config.json over config.toml for the Docker download path", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1116,8 +1138,8 @@ describe("functions download", () => { yield* functionsDownload({ ...baseFlags, useDocker: true }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - expect(runCommand?.args).toContain("supabase_network_toml-project"); - expect(runCommand?.args).not.toContain("supabase_network_json-project"); + expect(runCommand?.args).toContain("supabase_network_json-project"); + expect(runCommand?.args).not.toContain("supabase_network_toml-project"); }).pipe(Effect.provide(layer)); }); @@ -1129,6 +1151,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1166,6 +1189,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1207,6 +1231,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1246,6 +1271,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1288,6 +1314,7 @@ describe("functions download", () => { // modeling Docker not running. const child = mockChildProcessSpawner({ exitCode: 1 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1331,6 +1358,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["boom"] }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1372,6 +1400,7 @@ describe("functions download", () => { runStderr: ["invalid eszip v2"], }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1420,6 +1449,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["permission denied"] }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1473,6 +1503,7 @@ describe("functions download", () => { }; const child = mockChildProcessSpawner(spawnerOpts); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1498,7 +1529,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe( - `failed to create docker network: supabase_network_${PROJECT_ID}`, + `failed to create docker network: supabase_network_${yield* workdirProjectId}`, ); expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); @@ -1520,6 +1551,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockDockerRunSpawnFailure(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1576,6 +1608,7 @@ describe("functions download", () => { // not spawn a real `docker` process. const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1621,6 +1654,7 @@ describe("functions download", () => { }); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1663,6 +1697,7 @@ describe("functions download", () => { }); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1715,6 +1750,7 @@ describe("functions download", () => { : Effect.succeed(jsonResponse(request, 200, {})), }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1753,6 +1789,7 @@ describe("functions download", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1790,6 +1827,7 @@ describe("functions download", () => { }); const analytics = mockContextualAnalytics(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1823,6 +1861,7 @@ describe("functions download", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1858,6 +1897,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1903,6 +1943,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1950,6 +1991,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1988,6 +2030,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2026,6 +2069,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2068,6 +2112,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2148,6 +2193,7 @@ describe("functions download", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2181,11 +2227,12 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("labels the unbundle container with the resolved project id", () => { + it.live("labels the unbundle container with the project id derived from the workdir", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -2211,9 +2258,9 @@ describe("functions download", () => { expect(runCommand?.args).toEqual( expect.arrayContaining([ "--label", - `com.supabase.cli.project=${PROJECT_ID}`, + `com.supabase.cli.project=${yield* workdirProjectId}`, "--label", - `com.docker.compose.project=${PROJECT_ID}`, + `com.docker.compose.project=${yield* workdirProjectId}`, ]), ); }).pipe(Effect.provide(layer)); @@ -2233,6 +2280,7 @@ describe("functions download", () => { }); const child = mockChildProcessSpawner({ exitCode: 1 }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, diff --git a/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md index 5136b35007..4ca3f8e4d7 100644 --- a/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/new/SIDE_EFFECTS.md @@ -76,9 +76,9 @@ Emits a structured success result event with `path`, `function_name`, and `auth` - Creates a new Edge Function scaffold locally. - Requires exactly one argument: the function name. - `--auth` selects the auth-mode template (`none` | `apikey` | `user`, default: `apikey`). -- Best-effort config parsing is intentionally non-fatal here: malformed `config.toml` does not block scaffolding or config append. +- The example `curl` URL port and publishable key come from the shared resolved config, so `SUPABASE_API_PORT` and the other `SUPABASE_*` config overrides apply; a config that fails to load (unparsable file or invalid value) fails before anything is written. Without a config file the defaults apply. - The `[functions.]` config section is **appended** (`O_APPEND` semantics, `flag: "a"`), never rewritten, so the existing file is left byte-for-byte untouched and a partial write cannot truncate it. -- Existing-declaration detection scans the raw `config.toml` text (`^\s*\[functions\.\]\s*$`) rather than a parsed config map. This is a deliberate design choice: config loading here is non-fatal, so a raw-text scan stays deterministic even when the file fails to parse. For all well-formed configs the two approaches agree. +- Existing-declaration detection scans the raw `config.toml` text (`^\s*\[functions\.\]\s*$`) rather than a parsed config map. For all well-formed configs the two approaches agree. - IDE settings scaffolding (`.vscode`, `.idea`) only runs in `--output-format text`; json / stream-json runs are payload-only. - No Management API requests are made; all behavior is local filesystem work plus telemetry flush. - A non-existent `--workdir`/`SUPABASE_WORKDIR` now fails before any directory or file is created (CLI-2285) — previously a typo'd `--workdir` could scaffold a fresh `supabase/functions/…` tree (plus a new `config.toml`) at the wrong path. diff --git a/apps/cli/src/commands/functions/new/new.command.ts b/apps/cli/src/commands/functions/new/new.command.ts index 61c913bace..090e68266d 100644 --- a/apps/cli/src/commands/functions/new/new.command.ts +++ b/apps/cli/src/commands/functions/new/new.command.ts @@ -1,6 +1,8 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -42,4 +44,6 @@ export const functionsNewCommand = Command.make("new", config).pipe( functionsNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(functionsNewRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/functions/new/new.handler.ts b/apps/cli/src/commands/functions/new/new.handler.ts index 9bfc8170f2..1d5c479b76 100644 --- a/apps/cli/src/commands/functions/new/new.handler.ts +++ b/apps/cli/src/commands/functions/new/new.handler.ts @@ -1,4 +1,3 @@ -import { loadCliConfig } from "../../../command-internal/cli-config-load.ts"; import { defaultPublishableKey } from "../../../shared/stack-constants.ts"; import { Effect, FileSystem, Option, Path } from "effect"; @@ -12,8 +11,8 @@ import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { bold } from "../../../command-internal/colors.ts"; -import { shouldSearchAncestors } from "../../../command-internal/workdir-search.ts"; import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { FunctionsNewFlags } from "./new.command.ts"; @@ -32,8 +31,6 @@ import { renderFunctionsNewEntrypoint, } from "./new.templates.ts"; -const DEFAULT_LOCAL_API_PORT = 54321; - function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -92,15 +89,18 @@ const listExistingFunctionSlugs = Effect.fn("functions.new.listExistingSlugs")(f }); const resolveTemplateInputs = Effect.fn("functions.new.resolveTemplateInputs")(function* ( - cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, + cliSettings: { readonly workdir: string }, slug: string, ) { - const loaded = yield* loadCliConfig(cliSettings.workdir, { - search: shouldSearchAncestors(cliSettings), - }).pipe(Effect.orElseSucceed(() => null)); - yield* Effect.annotateCurrentSpan("config.found", loaded !== null); - const port = loaded?.config.api.port ?? DEFAULT_LOCAL_API_PORT; - const publishableKey = loaded?.config.auth.publishable_key ?? defaultPublishableKey; + const configValues = yield* CliConfigValues; + const resolvedConfig = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.none(), + }); + yield* Effect.annotateCurrentSpan("config.found", resolvedConfig.hasConfigFile); + const { config } = resolvedConfig.materialized; + const port = config.api.port; + const publishableKey = config.auth.publishable_key ?? defaultPublishableKey; return { url: `http://127.0.0.1:${port}/functions/v1/${slug}`, publishableKey, @@ -194,6 +194,8 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio const functionDir = path.join(cliSettings.workdir, relFunctionDir); const entrypointPath = path.join(cliSettings.workdir, relEntrypoint); + const templateInputs = yield* resolveTemplateInputs(cliSettings, flags.functionName); + yield* fs.makeDirectory(functionDir, { recursive: true }).pipe( Effect.mapError( (cause) => @@ -215,7 +217,6 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio }); } - const templateInputs = yield* resolveTemplateInputs(cliSettings, flags.functionName); yield* fs .writeFileString(entrypointPath, renderFunctionsNewEntrypoint(authMode, templateInputs)) .pipe( diff --git a/apps/cli/src/commands/functions/new/new.integration.test.ts b/apps/cli/src/commands/functions/new/new.integration.test.ts index 8888475bbe..5965799ae0 100644 --- a/apps/cli/src/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/commands/functions/new/new.integration.test.ts @@ -11,6 +11,7 @@ import { import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { functionsNew } from "./new.handler.ts"; import { FUNCTIONS_NEW_DENO_JSON, FUNCTIONS_NEW_NPMRC } from "./new.templates.ts"; @@ -46,6 +47,7 @@ function setup(options: SetupOptions = {}) { out.layer, telemetry.layer, cliSettings, + configValuesLayer(), mockTty({ stdinIsTty: options.stdinIsTty ?? false, stdoutIsTty: options.stdoutIsTty ?? false, @@ -162,7 +164,7 @@ describe("functions new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("appends config even when the existing config.toml is malformed", () => { + it.live("fails before writing anything when the existing config.toml is malformed", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -170,13 +172,31 @@ describe("functions new integration", () => { yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), "not valid toml ]["); - yield* functionsNew({ functionName: "after-bad-config", auth: "none" }); - const config = yield* fs.readFileString(path.join(workdir, "supabase", "config.toml")); - expect(config).toContain("not valid toml ]["); - expect(config).toContain("[functions.after-bad-config]"); + const exit = yield* functionsNew({ functionName: "after-bad-config", auth: "none" }).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* fs.exists(path.join(workdir, "supabase", "functions"))).toBe(false); + expect(yield* fs.readFileString(path.join(workdir, "supabase", "config.toml"))).toBe( + "not valid toml ][", + ); }).pipe(Effect.provide(layer)); }); + it.live("fails on an invalid SUPABASE_API_PORT without creating the function directory", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const exit = yield* functionsNew({ functionName: "hello", auth: "apikey" }).pipe(Effect.exit); + + expect(exitTag(exit)).toBe("CliConfigValueError"); + expect(yield* fs.exists(path.join(workdir, "supabase", "functions", "hello"))).toBe(false); + }).pipe(Effect.provide(layer), (body) => withConfigEnv({ SUPABASE_API_PORT: "abc" }, body)); + }); + it.live("warns and skips the config append when the function is already declared", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { @@ -322,8 +342,11 @@ describe("functions new integration", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // A directory at the config.toml path makes the append write fail (EISDIR). - yield* fs.makeDirectory(path.join(workdir, "supabase", "config.toml"), { recursive: true }); + // A read-only config.toml loads fine but makes the append write fail (EACCES). + const configPath = path.join(workdir, "supabase", "config.toml"); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString(configPath, 'project_id = "test-project"\n'); + yield* fs.chmod(configPath, 0o444); const exit = yield* Effect.exit(functionsNew({ functionName: "write-fail", auth: "apikey" })); expect(exitTag(exit)).toBe("FunctionsNewWriteError"); expect(telemetry.flushed).toBe(true); diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 9710713ee5..dc91a6fcd3 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -5,7 +5,7 @@ | Path | Format | When | | -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`resolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, read once through the shared resolved config (flag > shell > project `.env*` > config): feeds `env()` interpolation, the `SUPABASE_*` overrides (api port, project id, network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | | `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | | `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | @@ -50,9 +50,9 @@ back to local keys. No scheme/host validation is performed on the discovered URL | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | `SUPABASE_PROFILE` | resolves the profile / API base URL | no (defaults to `supabase`) | | `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | config-service override for project identity | no | +| `SUPABASE_PROJECT_ID` | overrides `project_id` for resource names | no | | `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | +| env vars referenced by `supabase/config.toml` | config interpolation; shell values win over the project `.env*` files | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | | `SUPABASE_USE_SLIM_IMAGES` | resolves the edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | | `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | @@ -148,8 +148,8 @@ Per-function TOML `env` remains the final override for that function. - named volume: `supabase_edge_runtime_` (mounted at `/root/.cache/deno`) - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`. -- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`) and passed into `loadCliConfig`. The command does not move/hide any project files. The resolved `BITBUCKET_CLONE_DIR` value is passed explicitly to Docker setup without changing the process environment. -- Config, project dotenv discovery, and function discovery all resolve from `` with no ancestor search (CLI-2285), so they can never disagree. +- Config values (`api.port`, `auth`, `edge_runtime`, `functions`, `project_id`) and `env()` interpolation come from the shared resolved config: shell environment over project `.env..local` / `.env.local` / `.env.` / `.env` over config.toml, so `SUPABASE_API_PORT` sets the published API port. The command does not move/hide any project files. The resolved `BITBUCKET_CLONE_DIR` value is passed explicitly to Docker setup without changing the process environment. +- Config, project dotenv discovery, and function discovery all resolve from `` with no ancestor search, so they can never disagree. - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container that stops on its own with exit code `0`, or that is torn down by an external supervisor (exit `129`/`130`/`131`/`143`, e.g. `supabase stop` in another terminal), or that is already gone by the time a follow-up inspect runs, all end the command successfully — each prints its own distinct line (see Output above) rather than the user-initiated `Stopped serving …` line, so scrollback can tell "I stopped it" from "the runtime walked out" or "a supervisor tore it down". In a `functions serve &` CI step this means a runtime that exits on its own does not fail the step; the distinct message is the only signal, and a downstream failure otherwise only surfaces later as connection-refused. Exit `137` (SIGKILL) fails the command: `State.OOMKilled` separates a container that hit its memory limit, which reports the memory-allocation remediation, from one killed by something the CLI cannot identify. A non-OOM `137` gets one re-inspect first — if `supabase stop` force-killed a runtime that ignored `SIGTERM` and the follow-up prune has already removed the container, the command still ends successfully instead of failing on a container that is already gone. Any other non-zero container exit fails the command; the error message includes the container id. Only a watched-file change restarts the container itself — none of these outcomes ever restart it. @@ -158,4 +158,4 @@ Per-function TOML `env` remains the final override for that function. - The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. - The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination ":/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. -- **Spec-strict import-map key matching (CLI-2179):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — rather than any-key prefix matching, so maps that relied on bare-key prefix matching get fewer bind mounts; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. +- **Spec-strict import-map key matching:** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key, so a bare-key prefix does not match and bind mounts are limited to spec-matching keys; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/commands/functions/serve/serve.command.ts b/apps/cli/src/commands/functions/serve/serve.command.ts index 095e34b795..2198a9ac94 100644 --- a/apps/cli/src/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/commands/functions/serve/serve.command.ts @@ -11,6 +11,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { functionsServe } from "./serve.handler.ts"; import { stackApiLayer } from "../../../command-internal/stack-api.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const functionsServeRuntimeLayer = Layer.mergeAll( @@ -72,4 +74,6 @@ export const functionsServeCommand = Command.make("serve", commandConfig).pipe( functionsServe(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(functionsServeRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(commandConfig), ); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 75793e6ce8..039a524028 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { FileSystem, Path } from "effect"; +import { FileSystem, Path, PlatformError } from "effect"; import { FetchHttpClient } from "effect/http"; import { describe, expect, it } from "@effect/vitest"; @@ -70,6 +70,7 @@ import { type FunctionsServeFlags, type FunctionsServeTimers, } from "../../../shared/functions/serve.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; const deployMockState = vi.hoisted(() => ({ runCalls: [] as Array<{ @@ -443,6 +444,7 @@ function setupServe(options: SetupOptions = {}) { const childSpawner = options.childSpawner ?? mockDockerLogSpawner([{ exitCode: 1 }]); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api: { @@ -717,7 +719,6 @@ describe("functions serve integration", () => { it.live.each([ ["per-function", "supabase/functions/hello/.env"], ["default", "supabase/functions/.env"], - ["project", ".env.development"], ] as const)( "rejects a BOM-prefixed %s env file without starting the runtime", ([, relativePath]) => @@ -1194,14 +1195,10 @@ describe("functions serve integration", () => { expect(error).toBeInstanceOf(Error); if (error instanceof Error) { - expect(error.message).toContain("failed to parse environment file:"); - expect(error.message).toContain(".env.development"); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); + expect(error.message).toBe("failed to parse environment file: .env.development"); } expect(deployMockState.runCalls).toHaveLength(0); - }); + }).pipe((body) => withEnvVar("SUPABASE_ENV", "development", body)); }); it.live("skips missing unused import map targets during serve startup", () => { @@ -1950,6 +1947,92 @@ describe("functions serve integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); + const dockerRestartHandler = (command: string, args: ReadonlyArray) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const restartPolicies = () => + deployMockState.runCalls + .filter((call) => call.command === "docker" && call.args[0] === "create") + .map((call) => call.args.join(" ").match(/--policy=(\S+)/)?.[1]); + + const restartOnce = Effect.fnUntraced(function* ( + change: Effect.Effect, + ) { + deployMockState.runHandler = dockerRestartHandler; + const path = yield* Path.Path; + const fileWatcher = mockFileWatcher(); + const childSpawner = mockDockerLogSpawner([ + { pending: true }, + { exitCode: 1, stderr: "docker logs exited with 1" }, + ]); + const { layer } = setupServe({ fileWatcher, childSpawner }); + const fiber = yield* functionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.forkChild({ startImmediately: true }), + ); + yield* waitFor( + () => + deployMockState.runCalls.filter( + (call) => call.command === "docker" && call.args[0] === "create", + ).length === 1, + "timed out waiting for first docker create", + ); + yield* change; + fileWatcher.emit([ + { + path: path.join(tempRoot.current, "supabase", "functions", "hello", "index.ts"), + type: "update", + }, + ]); + yield* Fiber.join(fiber).pipe(Effect.flip); + }); + + it.live("re-reads the project .env on each restart", () => + Effect.gen(function* () { + yield* writeCliConfig( + ['project_id = "test-project"', "[edge_runtime]", 'policy = "env(EDGE_POLICY)"', ""].join( + "\n", + ), + ); + yield* writeProjectFile("supabase/.env", "EDGE_POLICY=per_worker\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + + yield* restartOnce(writeProjectFile("supabase/.env", "EDGE_POLICY=oneshot\n")); + + expect(restartPolicies()).toEqual(["per_worker", "oneshot"]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("re-reads config.toml on each restart", () => + Effect.gen(function* () { + yield* writeCliConfig( + ['project_id = "test-project"', "[edge_runtime]", 'policy = "per_worker"', ""].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + + yield* restartOnce( + writeCliConfig( + ['project_id = "test-project"', "[edge_runtime]", 'policy = "oneshot"', ""].join("\n"), + ), + ); + + expect(restartPolicies()).toEqual(["per_worker", "oneshot"]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("stops serving cleanly on a process signal", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { @@ -3844,6 +3927,50 @@ describe("functions serve integration", () => { }, ); + it.live("publishes SUPABASE_API_PORT as the runtime's API port, ahead of config.toml", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + yield* writeCliConfig( + ['project_id = "test-project"', "[api]", "port = 54321", ""].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ + childSpawner: mockDockerLogSpawner([{ exitCode: 1, stderr: "api port env logs failed" }]), + }); + yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "create", + ); + if (dockerRun === undefined) { + throw new Error("expected docker create call"); + } + expect(yield* extractDockerEnvEntries(dockerRun)).toContain( + "SUPABASE_INTERNAL_HOST_PORT=5599", + ); + }).pipe((body) => withEnvVar("SUPABASE_API_PORT", "5599", body)); + }); + it.live( "does not publish default jwks fallbacks when signing_keys_path is configured but empty", () => { diff --git a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts index 0568e32e4b..56ae572dc3 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.handler.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.handler.ts @@ -146,7 +146,7 @@ const session = Effect.fn("functions.serve.session")(function* (flags: Functions composition.members.some((member) => member.id === id), ); const existing = members.find((instance) => instance.service === "functions"); - const envOverride = Option.isSome(flags.envFile) + const envFileOverride = Option.isSome(flags.envFile) ? yield* readStackFunctionsEnv(path.resolve(runtime.cwd, flags.envFile.value), false) : undefined; const cleanupWarning = (message: string, error: { readonly message: string }) => @@ -161,7 +161,7 @@ const session = Effect.fn("functions.serve.session")(function* (flags: Functions const saved = before.config.config; const desired = { ...saved, - ...(envOverride === undefined ? {} : { env: { ...saved.env, ...envOverride } }), + ...(envFileOverride === undefined ? {} : { env: { ...saved.env, ...envFileOverride } }), ...(Option.isSome(flags.noVerifyJwt) ? { verifyJwt: !flags.noVerifyJwt.value } : {}), }; const changed = !Equal.equals(saved, desired); @@ -258,7 +258,7 @@ const session = Effect.fn("functions.serve.session")(function* (flags: Functions : (yield* apiSource.credentials({ from: "runtime" })).apiUrl; if (apiUrl === undefined) return yield* invalidConfig("The stack has no runtime API URL."); const env = - envOverride ?? (yield* readStackFunctionsEnv(`${source.config.functionsRoot}/.env`, true)); + envFileOverride ?? (yield* readStackFunctionsEnv(`${source.config.functionsRoot}/.env`, true)); const { jwtSecret: _jwtSecret, ...sourceConfig } = source.config; const creation: FunctionsCreation = { ...source, @@ -267,7 +267,7 @@ const session = Effect.fn("functions.serve.session")(function* (flags: Functions jwks, apiUrl, databaseUrl, - env: { ...env, ...source.config.env, ...envOverride }, + env: { ...env, ...source.config.env, ...envFileOverride }, ...(Option.isSome(flags.noVerifyJwt) ? { verifyJwt: !flags.noVerifyJwt.value } : {}), }, endpoints: { ...source.endpoints, http: { port } }, diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index 48c0c8fa56..bae7cdc0d8 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -35,6 +35,7 @@ import { import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { functionsServeStack } from "./serve.stack.handler.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; type DatabaseInstance = Extract< Effect.Success>, @@ -311,6 +312,7 @@ const fixture = ( } satisfies StackApi["Service"]; const api = Layer.succeed(StackApi, apiService); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, api, mockCommandSettings({ diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts index 0e6063ee95..34001edba3 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -1,6 +1,8 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -61,4 +63,6 @@ export const genBearerJwtCommand = Command.make("bearer-jwt", config).pipe( genBearerJwt(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(genBearerJwtRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts index f9d0129429..bbf3cf7e17 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -1,6 +1,5 @@ -import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { Clock, Effect, Option } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; import { signJwtWithJwk } from "../../../command-internal/local-jwt.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -33,8 +32,6 @@ export const genBearerJwt = Effect.fn("gen.bearer-jwt")(function* (flags: GenBea const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; const output = yield* Output; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; return yield* Effect.gen(function* () { if (Option.isNone(flags.role)) { @@ -66,7 +63,6 @@ export const genBearerJwt = Effect.fn("gen.bearer-jwt")(function* (flags: GenBea }), }); - yield* loadProjectEnv(fs, path, cliSettings.workdir); const jwk = yield* resolveBearerJwtSigningKey(cliSettings.workdir); const payloadJson = encodeBearerJwtClaims(claims); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 0cd1bab410..a13132be29 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -12,6 +12,7 @@ import { mockStdin, mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { buildTestRuntime, mockCommandSettings, @@ -25,7 +26,7 @@ import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { genBearerJwtCommand, type GenBearerJwtFlags } from "./bearer-jwt.command.ts"; import { genBearerJwt } from "./bearer-jwt.handler.ts"; @@ -81,6 +82,7 @@ function setup(options: SetupOptions = {}) { }); const telemetry = options.trackTelemetry ? mockTelemetryStateTracked() : undefined; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, cliSettings, tty, telemetry: telemetry?.layer }), Layer.succeed(CliArgs, { args: [] }), mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), diff --git a/apps/cli/src/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/commands/gen/gen.signing-keys-config.ts index f38f77e160..1690112fce 100644 --- a/apps/cli/src/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/commands/gen/gen.signing-keys-config.ts @@ -1,9 +1,10 @@ -import { loadCliProjectEnvironment } from "@supabase/config/effect"; -import { loadCliConfig } from "../../command-internal/cli-config-load.ts"; -import { Config, Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import type { CliConfigKey } from "../../config/cli-config-key.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; +import { describeConfigLoadFailure } from "../../command-internal/resolved-config-context.ts"; import { assertDecodableJwkAlgorithm } from "../../command-internal/local-jwt.ts"; import { jsonKindName } from "../../command-internal/html-safe-json.ts"; -import { resolveProjectEnvironmentValues } from "../../command-internal/project-environment.ts"; /** * Shared `[auth].signing_keys_path` config-loading logic for `gen signing-key` and `gen @@ -14,8 +15,9 @@ import { resolveProjectEnvironmentValues } from "../../command-internal/project- export type StoredSigningKeyJwk = Readonly>; interface GenSigningKeysConfigPaths { - /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ + /** Workdir-relative path of the loaded config file, or `supabase/config.toml` when none exists. */ readonly configDisplayPath: string; + readonly configFormat: "toml" | "json"; /** * `[auth].enabled` from the resolved config (default `true`). The `signing_keys_path` file is * only read when this is `true` — see {@link resolveBearerJwtSigningKey} and {@link genSigningKey}. @@ -310,7 +312,7 @@ export function assertNoMalformedDuplicateJwkField(objectText: string): void { } /** - * Resolves `supabase/config.toml`'s display path and `[auth].signing_keys_path`'s + * Resolves the config file's display path and `[auth].signing_keys_path`'s * actual/display path — no file I/O on the keys path itself (see * {@link readSigningKeysFile} for that). */ @@ -319,73 +321,52 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* ( onConfigParseError: (message: string) => E, ) { const path = yield* Path.Path; - // Loads the dotenv cascade explicitly before `loadCliConfig` decodes `env(...)` TOML - // references — `loadCliConfig`'s own internal env resolution covers only - // `supabase/.env[.local]`, not `.env.[.local]` or `/.env`. - const supabaseEnv = yield* Config.option(Config.String("SUPABASE_ENV")).pipe( - Effect.mapError(() => - onConfigParseError("failed to resolve environment variable: SUPABASE_ENV"), - ), - ); - const supabaseEnvValue = Option.getOrElse( - Option.filter(supabaseEnv, (value) => value.length > 0), - () => "development", - ); - const projectEnv = yield* loadCliProjectEnvironment({ - cwd, - baseEnv: process.env, - search: false, - skipEnvLocal: supabaseEnvValue === "test", - }).pipe( - Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`)), - ); - const projectEnvValues = yield* resolveProjectEnvironmentValues( - projectEnv, - cwd, - supabaseEnvValue, - ).pipe(Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`))); - const loaded = yield* loadCliConfig(cwd, { - cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, - // `cwd` is already resolved (`CommandSettings.workdir`); `search: false` avoids climbing - // again, which would otherwise find an ancestor project's config when `--workdir` points - // below another project's root. `tomlOnly: true` because there is no JSON config format. - search: false, - tomlOnly: true, - }).pipe( - Effect.catchTag("CliConfigParseError", (cause) => - Effect.fail(onConfigParseError(`failed to parse ${cause.path}: ${String(cause.cause)}`)), - ), - ); - if (loaded === null) { - return { - configDisplayPath: path.join("supabase", "config.toml"), - authEnabled: true, - signingKeysPath: Option.none(), - } satisfies GenSigningKeysConfigPaths; - } + const values = yield* CliConfigValues; + // `cwd` is already resolved (`CommandSettings.workdir`); the resolved config never climbs to an + // ancestor project when `--workdir` points below another project's root. + const resolvedConfig = yield* values + .load({ workdir: cwd, projectRef: Option.none() }) + .pipe( + Effect.mapError((cause) => + onConfigParseError( + cause._tag === "CliConfigParseError" + ? `failed to parse ${cause.path}: ${String(cause.cause)}` + : describeConfigLoadFailure(cause), + ), + ), + ); + const read = (key: CliConfigKey) => + resolvedConfig.get(key).pipe( + Effect.map((resolved) => resolved.value), + Effect.mapError((cause) => onConfigParseError(describeConfigLoadFailure(cause))), + ); + const authEnabled = yield* read(CliConfigKeys.auth.enabled); + const configuredPath = yield* read(CliConfigKeys.auth.signingKeysPath); - // Display the config path relative to the project root; `loaded.path` is always absolute. - const projectRoot = path.dirname(path.dirname(loaded.path)); - const configDisplayPath = path.relative(projectRoot, loaded.path); - const authEnabled = loaded.config.auth.enabled; + const configDisplayPath = resolvedConfig.hasConfigFile + ? path.relative(cwd, resolvedConfig.loaded.path) + : path.join("supabase", "config.toml"); + const configFormat = resolvedConfig.hasConfigFile ? resolvedConfig.loaded.format : "toml"; - const configuredPath = loaded.config.auth.signing_keys_path; - if (configuredPath === undefined || configuredPath.length === 0) { + if (Option.isNone(configuredPath) || configuredPath.value.length === 0) { return { configDisplayPath, + configFormat, authEnabled, signingKeysPath: Option.none(), } satisfies GenSigningKeysConfigPaths; } - const resolvedPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.join(path.dirname(loaded.path), configuredPath); - const displayPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.relative(projectRoot, resolvedPath); + const signingKeysPath = configuredPath.value; + const resolvedPath = path.isAbsolute(signingKeysPath) + ? signingKeysPath + : path.join(cwd, "supabase", signingKeysPath); + const displayPath = path.isAbsolute(signingKeysPath) + ? signingKeysPath + : path.relative(cwd, resolvedPath); return { configDisplayPath, + configFormat, authEnabled, signingKeysPath: Option.some({ actualPath: resolvedPath, displayPath }), } satisfies GenSigningKeysConfigPaths; diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts index 74b1f21df5..4edbf0e377 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts @@ -1,6 +1,8 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -63,4 +65,6 @@ export const genSigningKeyCommand = Command.make("signing-key", config).pipe( genSigningKey(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(genSigningKeyRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts index 354704bde8..81a39e84d8 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts @@ -6,8 +6,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { emitSuccessTrailer } from "../../../shared/cli/success-trailer.ts"; import { findGitRootPath } from "../../../shared/git/git-root.ts"; -import { loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; +import { loadCliProjectEnvFiles } from "../../../shared/config/cli-config-env.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { DEFAULT_SIGNING_KEY } from "../../../command-internal/local-jwt.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -55,6 +56,7 @@ interface SigningKeyJwk { interface ResolvedSigningKeysConfig { readonly configDisplayPath: string; + readonly configFormat: "toml" | "json"; readonly configured: Option.Option<{ actualPath: string; displayPath: string; @@ -158,6 +160,7 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { if (Option.isNone(paths.signingKeysPath)) { return { configDisplayPath: paths.configDisplayPath, + configFormat: paths.configFormat, configured: Option.none(), } satisfies ResolvedSigningKeysConfig; } @@ -172,6 +175,7 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { : [{ ...DEFAULT_SIGNING_KEY }]; return { configDisplayPath: paths.configDisplayPath, + configFormat: paths.configFormat, configured: Option.some({ actualPath, displayPath, existingKeys }), } satisfies ResolvedSigningKeysConfig; }); @@ -217,8 +221,10 @@ export const genSigningKey = Effect.fn("gen.signing-key")(function* (flags: GenS return yield* Effect.gen(function* () { // Loaded here (not above) so a malformed `.env` still flushes telemetry: `SUPABASE_YES` // in `supabase/.env` must be able to auto-confirm the overwrite prompt below. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); + const projectEnv = yield* loadCliProjectEnvFiles(cliSettings.workdir).pipe( + Effect.mapError((cause) => new DbConfigLoadError({ message: cause.message })), + ); + const yes = yield* resolveYesWithProjectEnv({ ...projectEnv.values }); // The configured signing-keys file is validated before any key is // generated, so a broken config fails fast without doing throwaway crypto work. const signingKeysConfig = yield* loadSigningKeysConfig(cliSettings.workdir); @@ -229,8 +235,12 @@ export const genSigningKey = Effect.fn("gen.signing-key")(function* (flags: GenS const keyJson = yield* Schema.encodeEffect(signingKeyJson)(key).pipe(Effect.orDie); yield* output.raw(`${keyJson}\n`, "stdout"); const defaultPath = path.join("supabase", "signing_keys.json"); + const snippet = + signingKeysConfig.configFormat === "json" + ? '{ "auth": { "signing_keys_path": "./signing_keys.json" } }' + : '[auth]\nsigning_keys_path = "./signing_keys.json"'; yield* emitSuccessTrailer( - `\nTo enable JWT signing keys in your local project:\n1. Save the generated key to ${emphasize(defaultPath)}\n2. Update your ${emphasize(signingKeysConfig.configDisplayPath)} with the new keys path\n\n[auth]\nsigning_keys_path = "./signing_keys.json"\n\n`, + `\nTo enable JWT signing keys in your local project:\n1. Save the generated key to ${emphasize(defaultPath)}\n2. Update your ${emphasize(signingKeysConfig.configDisplayPath)} with the new keys path\n\n${snippet}\n\n`, ); return; } diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts index b954e17036..f54f6be623 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts @@ -1,18 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { - Cause, - ConfigProvider, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Schema, - Sink, - Stream, -} from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Sink, Stream } from "effect"; import { CliOutput, Command } from "effect/cli"; import { ChildProcessSpawner } from "effect/process"; @@ -24,6 +12,7 @@ import { mockTty, processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { buildTestRuntime, mockCommandSettings, @@ -34,7 +23,7 @@ import { withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { GLOBAL_FLAGS, YesFlag } from "../../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; @@ -105,6 +94,7 @@ function setup(options: SetupOptions = {}) { }); const telemetry = options.trackTelemetry ? mockTelemetryStateTracked() : undefined; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, cliSettings, tty, telemetry: telemetry?.layer }), Layer.succeed(YesFlag, options.yes ?? false), Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), @@ -308,7 +298,7 @@ describe("gen signing-key integration", () => { ); it.live( - "ignores a stray config.json and uses the default config.toml path in the local setup hint (CLI-1961)", + "names config.json and shows a JSON snippet in the local setup hint when it is the config file", () => { const { layer, out } = setup(); return Effect.gen(function* () { @@ -316,9 +306,28 @@ describe("gen signing-key integration", () => { yield* writeJsonConfig("{}\n"); yield* genSigningKey({ algorithm: "ES256", append: false }); + expect(out.stderrText).toContain(path.join("supabase", "config.json")); + expect(out.stderrText).toContain( + '{ "auth": { "signing_keys_path": "./signing_keys.json" } }', + ); + expect(out.stderrText).not.toContain("config.toml"); + expect(out.stderrText).not.toContain("[auth]"); + expect(out.stderrText).not.toContain(tempRoot.current); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "names config.toml and shows a TOML snippet in the local setup hint without a config file", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + const path = yield* Path.Path; + yield* genSigningKey({ algorithm: "ES256", append: false }); + expect(out.stderrText).toContain(path.join("supabase", "config.toml")); + expect(out.stderrText).toContain('[auth]\nsigning_keys_path = "./signing_keys.json"'); expect(out.stderrText).not.toContain("config.json"); - expect(out.stderrText).not.toContain(tempRoot.current); }).pipe(Effect.provide(layer)); }, ); @@ -344,6 +353,25 @@ describe("gen signing-key integration", () => { }, ); + it.live( + "reads signing_keys_path from SUPABASE_AUTH_SIGNING_KEYS_PATH over the config file", + () => { + const { layer, out } = setup({ stdinIsTty: false }); + return withConfigEnv( + { SUPABASE_AUTH_SIGNING_KEYS_PATH: "./signing_keys.json" }, + Effect.gen(function* () { + yield* writeConfig('[auth]\nsigning_keys_path = "./unused.json"\n'); + yield* writeSigningKeys("[]\n"); + + yield* genSigningKey({ algorithm: "ES256", append: false }); + + expect(yield* readSigningKeys()).toHaveLength(1); + expect(out.stderrText).toContain("JWT signing key appended to: "); + }), + ).pipe(Effect.provide(layer)); + }, + ); + it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { @@ -563,11 +591,9 @@ describe("gen signing-key integration", () => { yield* fs.writeFileString(path.join(supabaseDir, "from-env-local.json"), "[]\n"); yield* fs.writeFileString(path.join(supabaseDir, "from-env.json"), "[]\n"); - yield* genSigningKey({ algorithm: "ES256", append: false }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_ENV: "test" }, { preserveEmptyStrings: true }), - ), + yield* withConfigEnv( + { SUPABASE_ENV: "test" }, + genSigningKey({ algorithm: "ES256", append: false }), ); expect(out.stderrText).toContain(path.join("supabase", "from-env.json")); @@ -592,11 +618,9 @@ describe("gen signing-key integration", () => { "!=broken\n", ); - yield* genSigningKey({ algorithm: "ES256", append: false }).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_ENV: "test" }, { preserveEmptyStrings: true }), - ), + yield* withConfigEnv( + { SUPABASE_ENV: "test" }, + genSigningKey({ algorithm: "ES256", append: false }), ); expect(yield* readSigningKeys()).toHaveLength(1); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index e93da6b904..1443573e1f 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -16,14 +16,14 @@ is on, `--local` resolves the project stack through `DbConfigResolver` ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, and the implicit linked fallback — but not when `--schema` is also given on the two explicit flag paths, which skip this load entirely) or when `--db-url` needs schema defaults (`--schema` omitted). `--local` reads config.toml through its own tolerant reader (`readDbToml`) and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`GenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | -| `/supabase/config.toml` (`[db]` subtree only, never `config.json`) | TOML | every `--db-url` resolution, regardless of `--schema` — `DbConfigResolver` reads it through `readDbToml` to fill a passwordless local DSN's `[db].password` and to classify the target host as local vs. remote. A present-but-unparseable `config.toml` fails the command (`DbConfigLoadError`) even on this path, independent of the schema-selecting load above. | -| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI. Every `--db-url` resolution also reads it (same `.env..local`/`.env.local`/`.env.`/`.env` search) to populate the `PG*` fallbacks used while parsing the DSN. | -| `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--db-url`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | -| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | +| Path | Format | When | +| ----------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | +| `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, and the implicit linked fallback — but not when `--schema` is also given on the two explicit flag paths, which skip this load entirely) or when `--db-url` needs schema defaults (`--schema` omitted). `--local` reads the config through `readDbToml` and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`GenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | +| `/supabase/config.json` or `config.toml` (`[db]` values only) | JSON/TOML | every `--db-url` resolution, regardless of `--schema` — `DbConfigResolver` reads it through `readDbToml` (`config.json` preferred when both exist) to fill a passwordless local DSN's `[db].password` and to classify the target host as local vs. remote. A present-but-unparseable config file fails the command (`DbConfigLoadError`) even on this path, independent of the schema-selecting load above. | +| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI. Every `--db-url` resolution also reads it (same `.env..local`/`.env.local`/`.env.`/`.env` search) to populate the `PG*` fallbacks used while parsing the DSN. | +| `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--db-url`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | +| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | ## Files Written @@ -59,10 +59,10 @@ workdir). `--local` and `--db-url` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| `docker`/`podman container inspect supabase_db_` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | -| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | +| Command | When | Purpose | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running, against the shell-selected daemon (a project `.env*` is not forwarded to the spawn) | +| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | Any other `--lang` whose registry entry is out-of-process runs its own tool the same way: stdout is the output, stderr is folded into the error on @@ -75,20 +75,20 @@ way `--db-url` does. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation and `--db-url` resolution | no (defaults to `development`) | -| `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, `PGSERVICE`, `PGSERVICEFILE`, `PGAPPNAME` | libpq connection-setting fallbacks `--db-url` consults for whatever the DSN itself omits | no — each applies only when the DSN, a resolved `service`, and (where applicable) `[db].password` leave the setting unset | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | -| `SUPABASE_SERVICES_HOSTNAME` | host used to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | -| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | -| `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | schemas used on every target (`--local`, `--linked`, `--project-id`, `--db-url`) when `--schema` is omitted; wins over `[api].schemas` | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation and `--db-url` resolution | no (defaults to `development`) | +| `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, `PGSERVICE`, `PGSERVICEFILE`, `PGAPPNAME` | libpq connection-setting fallbacks `--db-url` consults for whatever the DSN itself omits | no — each applies only when the DSN, a resolved `service`, and (where applicable) `[db].password` leave the setting unset | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_PASSWORD` | database password for the `--linked`/`--project-id` paths and the implicit linked fallback; withheld (with a stderr `WARN: ignoring SUPABASE_DB_PASSWORD ...` line) when the workdir is linked to a different project, which mints a temporary login role instead. **Not read by `--local`**, which uses `[db].password` (default `postgres`) | no | +| `SUPABASE_SERVICES_HOSTNAME` | host used to reach the local database on the legacy Docker Compose stack; read from the shell env first, then project `.env*` | no (defaults to the shell `DOCKER_HOST`'s tcp host, else `127.0.0.1`) | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | +| `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | ## Exit Codes @@ -135,7 +135,13 @@ Not applicable. sorted group order: `local/linked/project-id/db-url`, plus `linked/project-id` against each of `postgrest-v9-compat`, `query-timeout`, and `swift-access-control`. - With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus - shell and nested dotenv overrides, matching the CLI. + shell and nested dotenv overrides, matching the CLI. The database password is the `[db].password` + value (default `postgres`); `SUPABASE_DB_PASSWORD` has no effect, and the command has no + `--password` flag. +- **Config value precedence** (ADR 0031): explicit flag > shell env > project `.env*` > config + (`config.json` over `config.toml`; a matched `[remotes.*]` block over the base document on + `--linked`/`--project-id`) > default. The whole config is decoded up front where it is loaded, + so an invalid value fails the command. - `--network-id` is rejected on this command: generation runs in-process over a direct PostgreSQL connection, and there is no container to join a Docker network from. Use a host-reachable `--db-url` instead. @@ -149,12 +155,12 @@ Not applicable. other host — including a loopback target where the DSN sets neither — uses the connection resolver's default, which is plaintext for a local target. - `--local` uses no TLS. -- **Project-ref language support:** +- **Project-ref language generation:** `--lang` accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths (`--linked`, `--project-id`, and the implicit linked fallback) use the Management API for TypeScript, and connect directly to the project database (temporary - login-role credentials, preview-branch fallback) for the other languages. Do not revert this to a hard error - (CLI-1623, CLI-1988). The mutex groups + login-role credentials, preview-branch fallback) for the other languages, so no `--db-url` is required. Do not + revert this to a hard error. The mutex groups only block `--swift-access-control` / `--query-timeout` when `--linked`/`--project-id` is passed _explicitly_ on the command line — that combination still always generates with defaults (`internal` access control, one-to-one detection on, 15s timeout). On the diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index cd61d45a91..ad4f0b2eb8 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -11,6 +11,8 @@ import { import { genTypes } from "./types.handler.ts"; import { GEN_TYPES_LANGUAGES, genTypesLanguageFlags } from "./types.languages.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { local: Flag.Boolean("local").pipe( @@ -116,4 +118,6 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( ), ), Command.provide(genTypesRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(commandConfig), ); diff --git a/apps/cli/src/commands/gen/types/types.errors.ts b/apps/cli/src/commands/gen/types/types.errors.ts index afb88e1547..a9d1d45fd1 100644 --- a/apps/cli/src/commands/gen/types/types.errors.ts +++ b/apps/cli/src/commands/gen/types/types.errors.ts @@ -61,7 +61,7 @@ export class GenTypesWorkdirError extends Data.TaggedError("GenTypesWorkdirError } /** - * `loadCliConfig` failed to parse the config, or found two `[remotes.*]` blocks with the same + * The config failed to parse, or has two `[remotes.*]` blocks with the same * `project_id`. Reports a clean parse failure instead of the raw `CliConfigParseError`/ * `DuplicateRemoteProjectIdError` tag leaking through as the message. */ diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 55fa190bbd..2d9603a87d 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,5 +1,3 @@ -import type { LoadedCliConfig } from "@supabase/config/effect"; -import { loadCliConfig } from "../../../command-internal/cli-config-load.ts"; import { ChildProcessSpawner } from "effect/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { getDomain } from "tldts"; @@ -12,6 +10,8 @@ import { scanArgvFlags, } from "../../../shared/cli/flag-groups.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import { ProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, @@ -30,12 +30,11 @@ import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; -import { tempPaths } from "../../../command-internal/temp-paths.ts"; +import { tempPaths } from "../../../shared/config/temp-paths.ts"; import { missingProjectConfigMessageEffect, relativeConfigPath, } from "../../../command-internal/workdir-project.ts"; -import { shouldSearchAncestors } from "../../../command-internal/workdir-search.ts"; import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -67,7 +66,6 @@ import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-fa import { defaultSchemas, localDbContainerId, - localDbPassword, parseQueryTimeoutMillis, rootCaBundle, } from "./types.shared.ts"; @@ -232,6 +230,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const projectRef = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const dbConfig = yield* DbConfigResolver; + const configValues = yield* CliConfigValues; const generator = yield* GenTypesGenerator; const backend = yield* currentStackBackend; @@ -253,41 +252,60 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // `projectRef` is passed only for the `--linked`/`--project-id` paths, so a matching // `[remotes.*]` overlay is merged in the same load; omitted for `--local`/`--db-url`. - const loadConfig = (projectRef?: string) => - loadCliConfig(cliSettings.workdir, { - ...(projectRef === undefined ? {} : { projectRef }), - search: shouldSearchAncestors(cliSettings), - }).pipe( - // `cause.path` names the actual failed file; `loadCliConfig` probes `config.json` - // before falling back to `config.toml`, so hardcoding `.toml` here would mislabel it. - // Caught before `requireProjectConfigWhenExplicit`, since a parse failure is distinct - // from the "no project here" case that guard handles. - Effect.catchTags({ - CliConfigParseError: (cause) => - new GenTypesParseConfigError({ - message: `failed to parse ${toRelativeConfigPath(cause.path)}: ${String(cause.cause)}`, - }), - DuplicateRemoteProjectIdError: (cause) => - new GenTypesParseConfigError({ message: cause.message }), - }), - Effect.flatMap(requireProjectConfigWhenExplicit), - Effect.tap((loaded) => Effect.annotateCurrentSpan("config.found", loaded !== null)), - ); + const loadApiSchemas = (projectRef?: string) => + configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(projectRef), + tolerateUnreadableLinkedRef: true, + }) + .pipe( + // `cause.path` names the actual failed file, which may be `config.json` or `config.toml`. + // Caught before `requireProjectConfigWhenExplicit`, since a parse failure is distinct + // from the "no project here" case that guard handles. + Effect.catchTags({ + CliConfigParseError: (cause) => + new GenTypesParseConfigError({ + message: `failed to parse ${toRelativeConfigPath(cause.path)}: ${String(cause.cause)}`, + }), + DuplicateRemoteProjectIdError: (cause) => + new GenTypesParseConfigError({ message: cause.message }), + CliConfigLoadError: (cause) => new GenTypesParseConfigError({ message: cause.message }), + }), + Effect.tap((resolvedConfig) => + requireProjectConfigWhenExplicit(resolvedConfig.hasConfigFile), + ), + Effect.tap((resolvedConfig) => + Effect.annotateCurrentSpan("config.found", resolvedConfig.hasConfigFile), + ), + Effect.flatMap((resolvedConfig) => + resolvedConfig.get(CliConfigKeys.api.schemas).pipe( + // Without a project the embedded default applies, not the schema's default list. + Effect.map(({ value, origin }) => + origin.tier === "default" && !resolvedConfig.hasConfigFile ? [] : value, + ), + ), + ), + ); // An explicit --workdir that holds no project must not silently resolve to the embedded // default schemas (dropping a declared [api].schemas and writing a public-only file at exit // 0). A defaulted workdir keeps the tolerant fallback. - const requireProjectConfigWhenExplicit = (loaded: LoadedCliConfig | null) => - loaded === null && cliSettings.explicitWorkdir + const requireProjectConfigWhenExplicit = (configFound: boolean) => + !configFound && cliSettings.explicitWorkdir ? Effect.gen(function* () { return yield* new GenTypesMissingProjectConfigError({ message: yield* missingProjectConfigMessageEffect(cliSettings), }); }) - : Effect.succeed(loaded); + : Effect.void; - const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => - defaultSchemas(apiSchemas); + // `--schema` skips the config load entirely, so a `--db-url --schema ...` invocation must not + // fail just because the workdir has no project config. + const resolveIncludedSchemas = (projectRef?: string) => + schemas.length > 0 + ? Effect.succeed(schemas) + : loadApiSchemas(projectRef).pipe(Effect.map(defaultSchemas)); /** * Sets a session-level `statement_timeout` and connect timeout from `--query-timeout` on @@ -496,10 +514,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); }); - const assertLocalDbRunning = ( - projectId: string, - projectEnvValues?: Readonly>, - ) => + const assertLocalDbRunning = (projectId: string) => Effect.scoped( Effect.gen(function* () { // Only the exit code and stderr matter; discard stdout so the inspect JSON can't @@ -511,8 +526,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) stdin: "ignore", stdout: "ignore", stderr: "pipe", - env: projectEnvValues === undefined ? undefined : { ...projectEnvValues }, - extendEnv: true, }, ); const [exitCode, stderr] = yield* Effect.all([ @@ -611,12 +624,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) "stack.backend": backend.kind, }); const config = yield* readDbToml(fs, path, cliSettings.workdir); - const projectEnvValues = Object.fromEntries( - Object.entries(config.projectEnv).filter(([key]) => key !== "SUPABASE_DB_PASSWORD"), - ); - const projectId = Option.getOrElse(config.projectId, () => - path.basename(cliSettings.workdir), - ); + const projectId = config.projectId; const paths = tempPaths(path, cliSettings.workdir); // Only forces v9 compat from the rest-version file's image tag when the database's @@ -645,13 +653,15 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return; } - yield* assertLocalDbRunning(projectId, projectEnvValues); + yield* assertLocalDbRunning(projectId); yield* runGenerate({ conn: { - host: yield* getHostname(projectEnvValues), + host: yield* getHostname( + yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), + ), port: config.port, user: "postgres", - password: yield* localDbPassword(), + password: config.password, database: "postgres", }, isLocal: true, @@ -663,17 +673,12 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) if (Option.isSome(flags.dbUrl)) { yield* Effect.annotateCurrentSpan("typegen.source", "db_url"); - // Skips the config load entirely when `--schema` is explicit, since the load's only - // output here is the schema fallback — a `--db-url --schema ...` invocation must not - // fail just because the workdir has no project config. - const loaded = schemas.length > 0 ? null : yield* loadConfig(); + const includedSchemas = yield* resolveIncludedSchemas(); const resolved = yield* dbConfig.resolve({ dbUrl: flags.dbUrl, connType: "db-url", dnsResolver, }); - const includedSchemas = - schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas ?? []); // A DSN's own `sslmode`/`sslrootcert` is honored as-is; only a known Supabase host with // neither set gets the CA pinned, matching the project-ref/branch paths. @@ -698,24 +703,14 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) if (flags.linked) { yield* Effect.annotateCurrentSpan("typegen.source", "linked"); const ref = yield* projectRef.resolve(Option.none()); - const loaded = schemas.length > 0 ? null : yield* loadConfig(ref); - yield* runProjectTypes( - ref, - schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), - false, - ); + yield* runProjectTypes(ref, yield* resolveIncludedSchemas(ref), false); return; } if (Option.isSome(flags.projectId)) { yield* Effect.annotateCurrentSpan("typegen.source", "project_id"); const ref = yield* projectRef.resolve(flags.projectId); - const loaded = schemas.length > 0 ? null : yield* loadConfig(ref); - yield* runProjectTypes( - ref, - schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), - true, - ); + yield* runProjectTypes(ref, yield* resolveIncludedSchemas(ref), true); return; } @@ -731,11 +726,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ), ), ); - const loaded = schemas.length > 0 ? null : yield* loadConfig(resolvedRef); - yield* runProjectTypes( - resolvedRef, - schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), - false, - ); + yield* runProjectTypes(resolvedRef, yield* resolveIncludedSchemas(resolvedRef), false); }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 3cd488dad6..64d2014a78 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -27,6 +27,7 @@ import { Stdio, Stream, } from "effect"; +import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; @@ -38,7 +39,9 @@ import { mockLinkedProjectCacheTracked, mockCommandPlatformApiService, mockTelemetryStateTracked, + withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import { toConnectError } from "../../../command-internal/db-connection.sql-pg.layer.ts"; @@ -491,6 +494,7 @@ const setup = Effect.fnUntraced(function* ( }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, runtime, BunServices.layer, child.layer, @@ -674,20 +678,23 @@ describe("gen types", () => { ); it.live( - "a defaulted workdir still picks up an ancestor project's configured api schemas from a subdirectory", + "a defaulted workdir picks up a config.json-only ancestor project's configured api schemas from a subdirectory", () => Effect.gen(function* () { - const root = yield* makeWorkdir("supabase-gen-types-ancestor-"); - yield* writeConfig( - root, - ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + const fs = yield* FileSystem.FileSystem; + const root = yield* makeWorkdir("supabase-gen-types-ancestor-json-"); + yield* fs.makeDirectory(path.join(root, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(root, "supabase", "config.json"), + '{"project_id":"demo","api":{"schemas":["ancestor_only"]}}', ); - const sub = path.join(root, "nested", "dir"); + const sub = path.join(root, "apps", "web"); yield* makeDirectory(sub); + const resolved = yield* resolveWorkdir(Option.none(), Option.none(), sub, path); const { layer, api } = yield* setup({ - workdir: sub, + workdir: resolved.workdir, skipConfig: true, - explicitWorkdir: false, + explicitWorkdir: resolved.explicit, projectId: Option.some(VALID_REF), projectTypes: "ok", }); @@ -852,6 +859,82 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + describe("SUPABASE_API_SCHEMAS", () => { + const withApiSchemas = (body: Effect.Effect) => + withEnvVar("SUPABASE_API_SCHEMAS", "auth,storage", body); + + it.live("selects the schemas for --linked ahead of config.toml", () => + withApiSchemas( + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-env-linked-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', "[api]", 'schemas = ["other"]'].join("\n"), + ); + const { layer, api } = yield* setup({ + workdir, + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public,auth,storage" }, + }); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("selects the schemas for --project-id without a project config", () => + withApiSchemas( + Effect.gen(function* () { + const { layer, api } = yield* setup({ skipConfig: true, projectTypes: "ok" }); + yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public,auth,storage" }, + }); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("selects the schemas for --local ahead of config.toml", () => + withApiSchemas( + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-env-local-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', "[api]", 'schemas = ["other"]'].join("\n"), + ); + const { layer, generator } = yield* setup({ workdir }); + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "auth", "storage"]); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("selects the schemas for --db-url ahead of config.toml", () => + withApiSchemas( + Effect.gen(function* () { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const workdir = yield* makeWorkdir("supabase-gen-types-env-dburl-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', "[api]", 'schemas = ["other"]'].join("\n"), + ); + const { layer, generator } = yield* setup({ + workdir, + args: ["gen", "types", "--db-url", dbUrl], + }); + yield* genTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe(Effect.provide(layer)); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "auth", "storage"]); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + }); + it.live("silently ignores --query-timeout for implicit linked TypeScript generation", () => Effect.gen(function* () { const { layer, out, api } = yield* setup({ @@ -2206,7 +2289,7 @@ describe("gen types", () => { ); yield* writeFile( path.join(workdir, "supabase", ".env"), - "DOCKER_HOST=project-daemon\nSUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io\nSUPABASE_USE_SLIM_IMAGES=1\nSUPABASE_DB_PASSWORD=dotenv-password\n", + "DOCKER_HOST=tcp://project-daemon:2375\nSUPABASE_DB_PASSWORD=dotenv-password\n", ); const { layer, out, linkedProjectCache, child, generator } = yield* setup({ workdir }); const configProvider = ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }); @@ -2224,14 +2307,8 @@ describe("gen types", () => { expect(child.calls[0]).toMatchObject({ command: "docker", args: ["container", "inspect", "supabase_db_demo"], - extendEnv: true, - }); - // Env forwarding from the config's `.env` excludes SUPABASE_DB_PASSWORD. - expect(child.calls[0]?.env).toEqual({ - DOCKER_HOST: "project-daemon", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - SUPABASE_USE_SLIM_IMAGES: "1", }); + expect(child.calls[0]?.env).toBeUndefined(); expect(generator.calls).toHaveLength(1); const call = generator.calls[0]; expect(call?.conn).toEqual({ @@ -2280,7 +2357,7 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("uses sanitized local docker ids and env-backed local db passwords", () => + it.live("uses sanitized local docker ids and the config.toml local db password", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-local-sanitized-"); yield* writeConfig( @@ -2293,6 +2370,7 @@ describe("gen types", () => { "", "[db]", "port = 54321", + 'password = "config-password"', ].join("\n"), ); const { layer, child, generator } = yield* setup({ workdir }); @@ -2300,7 +2378,7 @@ describe("gen types", () => { Effect.provide(layer), Effect.provideService( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "secret-password" }), + ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "ignored-env-password" }), ), ); @@ -2309,11 +2387,39 @@ describe("gen types", () => { "inspect", "supabase_db_demo_project_with_spaces", ]); - expect(generator.calls[0]?.conn.password).toBe("secret-password"); + expect(generator.calls[0]?.conn.password).toBe("config-password"); expect(generator.calls[0]?.conn.host).toBe("127.0.0.1"); }).pipe(Effect.provide(BunServices.layer)), ); + it.live("falls back to the default local db password and ignores SUPABASE_DB_PASSWORD", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-local-default-password-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, generator } = yield* setup({ workdir }); + yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "ignored-env-password" }), + ), + ); + + expect(generator.calls[0]?.conn.password).toBe("postgres"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("forces v9 compat when rest-version reports v9 on a modern database", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-local-v9-"); @@ -2598,9 +2704,7 @@ describe("gen types", () => { "inspect", localDbContainerId("configless-env-project"), ]); - expect(child.calls[0]?.env?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe( - "mirror.example.com", - ); + expect(child.calls[0]?.env).toBeUndefined(); expect(generator.calls[0]?.conn.host).toBe("host.docker.internal"); expect(generator.calls[0]?.conn.port).toBe(55432); expect(generator.calls[0]?.includedSchemas).toEqual([ @@ -2612,6 +2716,25 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("prefers a shell SUPABASE_SERVICES_HOSTNAME over supabase/.env", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-local-shell-hostname-"); + yield* writeConfig(workdir, 'project_id = "demo"\n'); + yield* writeFile( + path.join(workdir, "supabase", ".env"), + "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n", + ); + const { layer, generator } = yield* setup({ workdir }); + yield* withEnvVar( + "SUPABASE_SERVICES_HOSTNAME", + "shell-host", + genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), + ); + + expect(generator.calls[0]?.conn.host).toBe("shell-host"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("reports a generic inspect failure when docker emits no stderr", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-local-empty-stderr-"); diff --git a/apps/cli/src/commands/gen/types/types.layers.unit.test.ts b/apps/cli/src/commands/gen/types/types.layers.unit.test.ts index 350f4fdf6b..c0b49e4ac6 100644 --- a/apps/cli/src/commands/gen/types/types.layers.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.layers.unit.test.ts @@ -22,6 +22,7 @@ import { mockTelemetryStateLayer, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -86,6 +87,7 @@ function ambientStubs() { mockCommandCredentialsLayer, mockLinkedProjectCacheLayer, mockTelemetryStateLayer, + cliConfigValuesTestLayer, heavyServiceStubs, ); } diff --git a/apps/cli/src/commands/gen/types/types.shared.ts b/apps/cli/src/commands/gen/types/types.shared.ts index 646bfa3e9a..45c753ff15 100644 --- a/apps/cli/src/commands/gen/types/types.shared.ts +++ b/apps/cli/src/commands/gen/types/types.shared.ts @@ -1,4 +1,4 @@ -import { Config, Effect, Option, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { InvalidGenTypesDurationError } from "./types.errors.ts"; import caProd2021 from "./templates/prod-ca-2021.ts"; import caProd2025 from "./templates/prod-ca-2025.ts"; @@ -77,11 +77,6 @@ export function parseQueryTimeoutMillis( }); } -export const localDbPassword = Effect.fnUntraced(function* () { - const value = yield* Config.option(Config.String("SUPABASE_DB_PASSWORD")); - return Option.getOrElse(value, () => "postgres"); -}); - export function rootCaBundle() { return `${caStaging2021}${caProd2021}${caProd2025}`; } diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index 408e927d11..4658239bbb 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -1,26 +1,14 @@ -import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer } from "effect"; -import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; -import { getHostname } from "../../../command-internal/hostname.ts"; +import { Effect, Exit } from "effect"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { defaultSchemas, rootCaBundle, localDbContainerId, - localDbPassword, localNetworkId, parseQueryTimeoutMillis, } from "./types.shared.ts"; -const resolvePassword = (env: Record) => - localDbPassword().pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), - ), - ); - describe("parseQueryTimeoutMillis", () => { it.effect("parses compound durations", () => Effect.gen(function* () { @@ -99,23 +87,6 @@ describe("schema and id helpers", () => { expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); }); - it.effect("reads the services hostname and db password from the environment", () => - Effect.gen(function* () { - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "" })).toBe("127.0.0.1"); - expect(yield* getHostname({ SUPABASE_SERVICES_HOSTNAME: "db.internal" })).toBe("db.internal"); - expect(yield* resolvePassword({})).toBe("postgres"); - expect(yield* resolvePassword({ SUPABASE_DB_PASSWORD: "secret" })).toBe("secret"); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - ConfigProvider.layer(ConfigProvider.fromEnvRecord({ DOCKER_HOST: undefined })), - ), - ), - ), - ); - it("bundles the staging and production CA certificates", () => { expect(rootCaBundle().length).toBeGreaterThan(0); }); diff --git a/apps/cli/src/commands/init/SIDE_EFFECTS.md b/apps/cli/src/commands/init/SIDE_EFFECTS.md index d42d518a7c..9e09b1a070 100644 --- a/apps/cli/src/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/init/SIDE_EFFECTS.md @@ -28,10 +28,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_YES` | auto-accepts `-i` IDE prompts with the established stderr echo, same as `--yes` | no | -| `SUPABASE_EXPERIMENTAL_STACK` | when `1`, persist `[experimental] stack = true` and omit Docker-era default ports; `0`, unset, or empty writes the established template | no | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_YES` | auto-accepts `-i` IDE prompts with the established stderr echo, same as `--yes` | no | +| `SUPABASE_EXPERIMENTAL_STACK` | when true (`1`, `t`, `T`, `TRUE`, `true`, `True`), persist `[experimental] stack = true` and omit Docker-era default ports; false, unset, or empty writes the established template | no | ## Exit Codes @@ -39,7 +39,7 @@ | ---- | ------------------------------------------------------------------------------------ | | `0` | success - prints "Finished supabase init." | | `1` | `supabase/config.toml` already exists and `--force` was not provided | -| `1` | `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` | +| `1` | `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value that is not a boolean | | `1` | permission denied writing config file | | `1` | an existing `.vscode/settings.json` / `.vscode/extensions.json` is not valid JSON(C) | @@ -73,10 +73,10 @@ failed to create config file: open supabase\config.toml: The file exists. Run supabase init --force to overwrite existing config file. ``` -When `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` (stderr; the second line is the generic debug hint appended on error): +When `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value that is not a boolean such as `1`, `0`, `true` or `false` (stderr; the second line is the generic debug hint appended on error): ``` -SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set +Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false. Try rerunning the command with --debug to troubleshoot the error. ``` diff --git a/apps/cli/src/commands/init/init.handler.ts b/apps/cli/src/commands/init/init.handler.ts index 41218b3095..62d96e99ea 100644 --- a/apps/cli/src/commands/init/init.handler.ts +++ b/apps/cli/src/commands/init/init.handler.ts @@ -2,10 +2,7 @@ import { Effect, Option, Path } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { initProject } from "../../shared/init/project-init.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { - experimentalFeatureEnv, - resolveExperimentalFeature, -} from "../../command-internal/experimental-feature.ts"; +import { resolveExperimentalFeature } from "../../command-internal/experimental-feature.ts"; import { WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; import { InitConfigExistsError } from "./init.errors.ts"; import type { InitFlags } from "./init.command.ts"; @@ -19,7 +16,6 @@ export const init = Effect.fn("init")(function* (flags: InitFlags) { const experimentalStack = yield* resolveExperimentalFeature({ feature: "stack", configValue: Effect.succeed(false), - env: yield* experimentalFeatureEnv("stack"), }); const result = yield* initProject({ diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index ed8983c542..324c9ada36 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -375,7 +375,7 @@ describe("init", () => { }); }); - it.live("fails closed when SUPABASE_EXPERIMENTAL_STACK is not 0 or 1", () => { + it.live("fails closed when SUPABASE_EXPERIMENTAL_STACK is not a boolean", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -388,10 +388,12 @@ describe("init", () => { const error = findFailure(exit); expect(error["_tag"]).toBe("ExperimentalFeatureFlagError"); - expect(error["message"]).toBe("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + expect(error["message"]).toBe( + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.', + ); expect(yield* renderFailureToStderr(exit)).toEqual([ - "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set\n", + 'Invalid SUPABASE_EXPERIMENTAL_STACK="yes" (sets experimental.stack): expected true or false.\n', "Try rerunning the command with --debug to troubleshoot the error.\n", ]); }); diff --git a/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts b/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts index 5f45a1e712..ae3bfdeeb6 100644 --- a/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts +++ b/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbBloat } from "./bloat.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbBloatCommand = Command.make("bloat", INSPECT_DB_FLAGS).pipe( Command.withDescription("Estimates space allocated to a relation that is full of dead tuples."), Command.withShortDescription("Show relation bloat"), Command.withHandler(inspectDbCommandHandler(inspectDbBloat)), Command.provide(inspectDbRuntimeLayer("bloat")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts b/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts index 84af68b454..41a70d31e6 100644 --- a/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts +++ b/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbBlocking } from "./blocking.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbBlockingCommand = Command.make("blocking", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbBlockingCommand = Command.make("blocking", INSPECT_DB_FLAG Command.withShortDescription("Show blocking queries"), Command.withHandler(inspectDbCommandHandler(inspectDbBlocking)), Command.provide(inspectDbRuntimeLayer("blocking")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/cache-hit/cache-hit.command.ts b/apps/cli/src/commands/inspect/db/cache-hit/cache-hit.command.ts index 28b5329210..8da34f98fa 100644 --- a/apps/cli/src/commands/inspect/db/cache-hit/cache-hit.command.ts +++ b/apps/cli/src/commands/inspect/db/cache-hit/cache-hit.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbCacheHit } from "./cache-hit.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbCacheHitCommand = Command.make("cache-hit", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbCacheHitCommand = Command.make("cache-hit", INSPECT_DB_FLA Command.withShortDescription("Show cache hit rates (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbCacheHit)), Command.provide(inspectDbRuntimeLayer("cache-hit")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/calls/calls.command.ts b/apps/cli/src/commands/inspect/db/calls/calls.command.ts index b5bf7787ae..5d34ef0770 100644 --- a/apps/cli/src/commands/inspect/db/calls/calls.command.ts +++ b/apps/cli/src/commands/inspect/db/calls/calls.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbCalls } from "./calls.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbCallsCommand = Command.make("calls", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show queries from pg_stat_statements ordered by total times called."), Command.withShortDescription("Show queries by call count"), Command.withHandler(inspectDbCommandHandler(inspectDbCalls)), Command.provide(inspectDbRuntimeLayer("calls")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/db-stats/db-stats.command.ts b/apps/cli/src/commands/inspect/db/db-stats/db-stats.command.ts index 8f7967deb0..ffd2e726db 100644 --- a/apps/cli/src/commands/inspect/db/db-stats/db-stats.command.ts +++ b/apps/cli/src/commands/inspect/db/db-stats/db-stats.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbDbStats } from "./db-stats.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbDbStatsCommand = Command.make("db-stats", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show stats such as cache hit rates, total sizes, and WAL size."), Command.withShortDescription("Show database stats"), Command.withHandler(inspectDbCommandHandler(inspectDbDbStats)), Command.provide(inspectDbRuntimeLayer("db-stats")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/index-sizes/index-sizes.command.ts b/apps/cli/src/commands/inspect/db/index-sizes/index-sizes.command.ts index b427db6ef1..39ec9ab979 100644 --- a/apps/cli/src/commands/inspect/db/index-sizes/index-sizes.command.ts +++ b/apps/cli/src/commands/inspect/db/index-sizes/index-sizes.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbIndexSizes } from "./index-sizes.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbIndexSizesCommand = Command.make("index-sizes", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbIndexSizesCommand = Command.make("index-sizes", INSPECT_DB Command.withShortDescription("Show individual index sizes (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbIndexSizes)), Command.provide(inspectDbRuntimeLayer("index-sizes")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/index-stats/index-stats.command.ts b/apps/cli/src/commands/inspect/db/index-stats/index-stats.command.ts index 8f9219122e..c6bd88942b 100644 --- a/apps/cli/src/commands/inspect/db/index-stats/index-stats.command.ts +++ b/apps/cli/src/commands/inspect/db/index-stats/index-stats.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbIndexStats } from "./index-stats.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbIndexStatsCommand = Command.make("index-stats", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbIndexStatsCommand = Command.make("index-stats", INSPECT_DB Command.withShortDescription("Show index stats"), Command.withHandler(inspectDbCommandHandler(inspectDbIndexStats)), Command.provide(inspectDbRuntimeLayer("index-stats")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/index-usage/index-usage.command.ts b/apps/cli/src/commands/inspect/db/index-usage/index-usage.command.ts index 4ca772603d..7817e0127a 100644 --- a/apps/cli/src/commands/inspect/db/index-usage/index-usage.command.ts +++ b/apps/cli/src/commands/inspect/db/index-usage/index-usage.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbIndexUsage } from "./index-usage.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbIndexUsageCommand = Command.make("index-usage", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbIndexUsageCommand = Command.make("index-usage", INSPECT_DB Command.withShortDescription("Show index efficiency (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbIndexUsage)), Command.provide(inspectDbRuntimeLayer("index-usage")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/locks/locks.command.ts b/apps/cli/src/commands/inspect/db/locks/locks.command.ts index 0409ca92c1..de978e3d73 100644 --- a/apps/cli/src/commands/inspect/db/locks/locks.command.ts +++ b/apps/cli/src/commands/inspect/db/locks/locks.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbLocks } from "./locks.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbLocksCommand = Command.make("locks", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show queries which have taken out an exclusive lock on a relation."), Command.withShortDescription("Show exclusive locks"), Command.withHandler(inspectDbCommandHandler(inspectDbLocks)), Command.provide(inspectDbRuntimeLayer("locks")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/long-running-queries/long-running-queries.command.ts b/apps/cli/src/commands/inspect/db/long-running-queries/long-running-queries.command.ts index 759a13dd57..4f36336caf 100644 --- a/apps/cli/src/commands/inspect/db/long-running-queries/long-running-queries.command.ts +++ b/apps/cli/src/commands/inspect/db/long-running-queries/long-running-queries.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbLongRunningQueries } from "./long-running-queries.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbLongRunningQueriesCommand = Command.make( "long-running-queries", @@ -11,4 +13,6 @@ export const inspectDbLongRunningQueriesCommand = Command.make( Command.withShortDescription("Show long-running queries"), Command.withHandler(inspectDbCommandHandler(inspectDbLongRunningQueries)), Command.provide(inspectDbRuntimeLayer("long-running-queries")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts b/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts index 3da925735d..3c0aaaad53 100644 --- a/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts +++ b/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbOutliers } from "./outliers.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbOutliersCommand = Command.make("outliers", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show queries from pg_stat_statements ordered by total execution time."), Command.withShortDescription("Show query outliers by time"), Command.withHandler(inspectDbCommandHandler(inspectDbOutliers)), Command.provide(inspectDbRuntimeLayer("outliers")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/replication-slots/replication-slots.command.ts b/apps/cli/src/commands/inspect/db/replication-slots/replication-slots.command.ts index dae91a37c5..8bc97c09ed 100644 --- a/apps/cli/src/commands/inspect/db/replication-slots/replication-slots.command.ts +++ b/apps/cli/src/commands/inspect/db/replication-slots/replication-slots.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbReplicationSlots } from "./replication-slots.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbReplicationSlotsCommand = Command.make( "replication-slots", @@ -11,4 +13,6 @@ export const inspectDbReplicationSlotsCommand = Command.make( Command.withShortDescription("Show replication slots"), Command.withHandler(inspectDbCommandHandler(inspectDbReplicationSlots)), Command.provide(inspectDbRuntimeLayer("replication-slots")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/role-configs/role-configs.command.ts b/apps/cli/src/commands/inspect/db/role-configs/role-configs.command.ts index 08bfb172b3..a246b0326f 100644 --- a/apps/cli/src/commands/inspect/db/role-configs/role-configs.command.ts +++ b/apps/cli/src/commands/inspect/db/role-configs/role-configs.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbRoleConfigs } from "./role-configs.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbRoleConfigsCommand = Command.make("role-configs", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbRoleConfigsCommand = Command.make("role-configs", INSPECT_ Command.withShortDescription("Show role configs (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbRoleConfigs)), Command.provide(inspectDbRuntimeLayer("role-configs")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/role-connections/role-connections.command.ts b/apps/cli/src/commands/inspect/db/role-connections/role-connections.command.ts index 5736bf9f1e..c339fb55ca 100644 --- a/apps/cli/src/commands/inspect/db/role-connections/role-connections.command.ts +++ b/apps/cli/src/commands/inspect/db/role-connections/role-connections.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbRoleConnections } from "./role-connections.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbRoleConnectionsCommand = Command.make( "role-connections", @@ -13,4 +15,6 @@ export const inspectDbRoleConnectionsCommand = Command.make( Command.withShortDescription("Show role connections (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbRoleConnections)), Command.provide(inspectDbRuntimeLayer("role-connections")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/role-stats/role-stats.command.ts b/apps/cli/src/commands/inspect/db/role-stats/role-stats.command.ts index 569a4c3f9d..8cb73fcb83 100644 --- a/apps/cli/src/commands/inspect/db/role-stats/role-stats.command.ts +++ b/apps/cli/src/commands/inspect/db/role-stats/role-stats.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbRoleStats } from "./role-stats.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbRoleStatsCommand = Command.make("role-stats", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show information about roles on the database."), Command.withShortDescription("Show role stats"), Command.withHandler(inspectDbCommandHandler(inspectDbRoleStats)), Command.provide(inspectDbRuntimeLayer("role-stats")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/seq-scans/seq-scans.command.ts b/apps/cli/src/commands/inspect/db/seq-scans/seq-scans.command.ts index b940f61b51..4ca03d1c4c 100644 --- a/apps/cli/src/commands/inspect/db/seq-scans/seq-scans.command.ts +++ b/apps/cli/src/commands/inspect/db/seq-scans/seq-scans.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbSeqScans } from "./seq-scans.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbSeqScansCommand = Command.make("seq-scans", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbSeqScansCommand = Command.make("seq-scans", INSPECT_DB_FLA Command.withShortDescription("Show sequential scans (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbSeqScans)), Command.provide(inspectDbRuntimeLayer("seq-scans")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts b/apps/cli/src/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts index 5de6d40288..c5147a58c5 100644 --- a/apps/cli/src/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts +++ b/apps/cli/src/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTableIndexSizes } from "./table-index-sizes.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTableIndexSizesCommand = Command.make( "table-index-sizes", @@ -13,4 +15,6 @@ export const inspectDbTableIndexSizesCommand = Command.make( Command.withShortDescription("Show table index sizes (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbTableIndexSizes)), Command.provide(inspectDbRuntimeLayer("table-index-sizes")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/table-record-counts/table-record-counts.command.ts b/apps/cli/src/commands/inspect/db/table-record-counts/table-record-counts.command.ts index 416dde502e..75a867cc77 100644 --- a/apps/cli/src/commands/inspect/db/table-record-counts/table-record-counts.command.ts +++ b/apps/cli/src/commands/inspect/db/table-record-counts/table-record-counts.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTableRecordCounts } from "./table-record-counts.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTableRecordCountsCommand = Command.make( "table-record-counts", @@ -13,4 +15,6 @@ export const inspectDbTableRecordCountsCommand = Command.make( Command.withShortDescription("Show table record counts (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbTableRecordCounts)), Command.provide(inspectDbRuntimeLayer("table-record-counts")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/table-sizes/table-sizes.command.ts b/apps/cli/src/commands/inspect/db/table-sizes/table-sizes.command.ts index b23475ee2b..7337f6c3c2 100644 --- a/apps/cli/src/commands/inspect/db/table-sizes/table-sizes.command.ts +++ b/apps/cli/src/commands/inspect/db/table-sizes/table-sizes.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTableSizes } from "./table-sizes.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTableSizesCommand = Command.make("table-sizes", INSPECT_DB_FLAGS).pipe( Command.withDescription( @@ -10,4 +12,6 @@ export const inspectDbTableSizesCommand = Command.make("table-sizes", INSPECT_DB Command.withShortDescription("Show table sizes (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbTableSizes)), Command.provide(inspectDbRuntimeLayer("table-sizes")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/table-stats/table-stats.command.ts b/apps/cli/src/commands/inspect/db/table-stats/table-stats.command.ts index 272e0a0637..aeb480d295 100644 --- a/apps/cli/src/commands/inspect/db/table-stats/table-stats.command.ts +++ b/apps/cli/src/commands/inspect/db/table-stats/table-stats.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbTableStats } from "./table-stats.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTableStatsCommand = Command.make("table-stats", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show combined table size, index size, and estimated row count."), Command.withShortDescription("Show table stats"), Command.withHandler(inspectDbCommandHandler(inspectDbTableStats)), Command.provide(inspectDbRuntimeLayer("table-stats")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/total-index-size/total-index-size.command.ts b/apps/cli/src/commands/inspect/db/total-index-size/total-index-size.command.ts index 7b93bef285..731086d218 100644 --- a/apps/cli/src/commands/inspect/db/total-index-size/total-index-size.command.ts +++ b/apps/cli/src/commands/inspect/db/total-index-size/total-index-size.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTotalIndexSize } from "./total-index-size.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTotalIndexSizeCommand = Command.make( "total-index-size", @@ -11,4 +13,6 @@ export const inspectDbTotalIndexSizeCommand = Command.make( Command.withShortDescription("Show total index size (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbTotalIndexSize)), Command.provide(inspectDbRuntimeLayer("total-index-size")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts b/apps/cli/src/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts index 0b90653b79..bfcc1081a1 100644 --- a/apps/cli/src/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts +++ b/apps/cli/src/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTotalTableSizes } from "./total-table-sizes.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTotalTableSizesCommand = Command.make( "total-table-sizes", @@ -13,4 +15,6 @@ export const inspectDbTotalTableSizesCommand = Command.make( Command.withShortDescription("Show total table sizes (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbTotalTableSizes)), Command.provide(inspectDbRuntimeLayer("total-table-sizes")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/traffic-profile/traffic-profile.command.ts b/apps/cli/src/commands/inspect/db/traffic-profile/traffic-profile.command.ts index a652d4aeee..aa2cefbaa9 100644 --- a/apps/cli/src/commands/inspect/db/traffic-profile/traffic-profile.command.ts +++ b/apps/cli/src/commands/inspect/db/traffic-profile/traffic-profile.command.ts @@ -2,6 +2,8 @@ import { Command } from "effect/cli"; import { inspectDbTrafficProfile } from "./traffic-profile.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbTrafficProfileCommand = Command.make( "traffic-profile", @@ -13,4 +15,6 @@ export const inspectDbTrafficProfileCommand = Command.make( Command.withShortDescription("Show traffic profile"), Command.withHandler(inspectDbCommandHandler(inspectDbTrafficProfile)), Command.provide(inspectDbRuntimeLayer("traffic-profile")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/unused-indexes/unused-indexes.command.ts b/apps/cli/src/commands/inspect/db/unused-indexes/unused-indexes.command.ts index 329b9a15b8..216b96db33 100644 --- a/apps/cli/src/commands/inspect/db/unused-indexes/unused-indexes.command.ts +++ b/apps/cli/src/commands/inspect/db/unused-indexes/unused-indexes.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbUnusedIndexes } from "./unused-indexes.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbUnusedIndexesCommand = Command.make("unused-indexes", INSPECT_DB_FLAGS).pipe( Command.withDescription('Show indexes with low usage. Deprecated: use "index-stats" instead.'), Command.withShortDescription("Show unused indexes (deprecated)"), Command.withHandler(inspectDbCommandHandler(inspectDbUnusedIndexes)), Command.provide(inspectDbRuntimeLayer("unused-indexes")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts b/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts index c48c01f261..dc7e9dd4f3 100644 --- a/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts +++ b/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts @@ -2,10 +2,14 @@ import { Command } from "effect/cli"; import { inspectDbVacuumStats } from "./vacuum-stats.handler.ts"; import { INSPECT_DB_FLAGS, inspectDbCommandHandler } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; +import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; export const inspectDbVacuumStatsCommand = Command.make("vacuum-stats", INSPECT_DB_FLAGS).pipe( Command.withDescription("Show statistics related to vacuum operations per table."), Command.withShortDescription("Show vacuum stats"), Command.withHandler(inspectDbCommandHandler(inspectDbVacuumStats)), Command.provide(inspectDbRuntimeLayer("vacuum-stats")), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(INSPECT_DB_FLAGS), ); diff --git a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts index 0a49436872..40935ef87c 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -17,6 +17,7 @@ import { mockTelemetryStateLayer, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { @@ -74,6 +75,7 @@ function ambientStubs() { mockCommandCredentialsLayer, mockLinkedProjectCacheLayer, mockTelemetryStateLayer, + cliConfigValuesTestLayer, heavyServiceStubs, ); } diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 1981e3a76a..b686ad1322 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -16,7 +16,10 @@ validating those CSVs. | `//.csv` ×14 | CSV | read back in-memory for rule evaluation | A **missing** `config.toml` is fine (defaults apply); a -**malformed** file aborts the command. +**malformed** file, an invalid value (including a non-string rule field such as `query = 123` or +`pass = true`), a single `[experimental.inspect.rules]` table (write `[[experimental.inspect.rules]]`) +or an unknown rule key aborts the command before connecting. Rules and `env(VAR)` expansion come from the shared resolved config +(flag > shell > project `.env*` > config). ## Files Written diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index b29123d452..eb7b6a7ca4 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -7,6 +7,8 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { inspectReport } from "./report.handler.ts"; import { inspectBaseLayer } from "../inspect.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -55,4 +57,6 @@ export const inspectReportCommand = Command.make("report", config).pipe( ), ), Command.provide(Layer.merge(inspectBaseLayer, commandRuntimeLayer(["inspect", "report"]))), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/inspect/report/report.config.ts b/apps/cli/src/commands/inspect/report/report.config.ts index c592a4e78b..97dab54f20 100644 --- a/apps/cli/src/commands/inspect/report/report.config.ts +++ b/apps/cli/src/commands/inspect/report/report.config.ts @@ -1,141 +1,41 @@ -import { Effect, type FileSystem, Match, Option, type Path } from "effect"; -import * as SmolToml from "smol-toml"; +import { Effect, Option } from "effect"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import { - configEnvOption, - envRefName, - envRefValue, - loadProjectEnv, -} from "../../../command-internal/db-config.toml-read.ts"; +import { getDocumentValue, isDocumentRecord } from "../../../config/cli-config-document.ts"; +import { CliConfigValues } from "../../../config/cli-config-values.service.ts"; import type { InspectRule } from "./report.rules.ts"; -type RawDoc = { readonly [key: string]: unknown }; - -function asRecord(value: unknown): RawDoc | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as RawDoc) - : undefined; -} +const RULE_FIELDS: ReadonlyArray = ["query", "name", "pass", "fail"]; /** - * Coerces a rule field to a string: numbers/bigints become their decimal string, booleans - * become `"1"`/`"0"`, a missing field is `""`, and anything else (a nested table, array, or - * datetime) returns `undefined` so the caller can fail with `DbConfigLoadError`. + * Reads `[experimental.inspect.rules]` from the resolved config; when non-empty, these rules + * replace the embedded defaults. A missing field is the empty string, and an unknown key aborts + * the load so a misspelled field is not silently dropped. */ -function coerceRuleField(value: unknown): string | undefined { - if (value === undefined) return ""; - if (typeof value === "string") return value; - if (typeof value === "number") return String(value); - if (typeof value === "bigint") return value.toString(); - if (typeof value === "boolean") return value ? "1" : "0"; - return undefined; -} - -/** - * Reads `[experimental.inspect.rules]` from `/supabase/config.toml`; when present and - * non-empty, these rules replace the embedded defaults. A missing file yields `[]`; a malformed - * file fails with `DbConfigLoadError`. Each field goes through `env(VAR)` expansion against the - * shell environment, then the project `.env` files. - */ -export const readInspectRules = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, -) { - const configPath = path.join(workdir, "supabase", "config.toml"); - - const content = yield* fs.readFileString(configPath).pipe( - Effect.map((text): string | undefined => text), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.void), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), - ), - ), - ), - ); - - if (content === undefined) return [] as ReadonlyArray; - - const doc = yield* Effect.try({ - try: () => asRecord(SmolToml.parse(content)), - catch: (cause) => - new DbConfigLoadError({ - message: `failed to load config: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - - const inspect = asRecord(asRecord(doc?.["experimental"])?.["inspect"]); - const rawRules = inspect?.["rules"]; - - // A single table wraps into one rule entry; an empty table yields no rules. A scalar also - // wraps into an entry, which fails the table check below. - let entries: ReadonlyArray; - if (rawRules === undefined) { - return [] as ReadonlyArray; - } else if (Array.isArray(rawRules)) { - entries = rawRules; - } else { - const asMap = asRecord(rawRules); - if (asMap !== undefined && Object.keys(asMap).length === 0) { - return [] as ReadonlyArray; - } - entries = [rawRules]; - } - if (entries.length === 0) return [] as ReadonlyArray; - - const RULE_FIELDS = ["query", "name", "pass", "fail"] as const; - - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - const expandEnv = Effect.fnUntraced(function* (value: string) { - const name = envRefName(value); - if (name === undefined) return value; - const fromEnv = yield* configEnvOption(name); - return envRefValue( - value, - Option.getOrElse(fromEnv, () => projectEnv[name]), - ); - }); - - const rules: Array = []; - for (let index = 0; index < entries.length; index++) { - const record = asRecord(entries[index]); - // Rejects a non-table entry (e.g. `rules = ["foo"]`) instead of silently skipping it. - if (record === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}] expected a table`, - }); - } - // An unknown or misspelled key aborts the whole load instead of being ignored. - const unknownKeys = Object.keys(record).filter( - (key) => !(RULE_FIELDS as ReadonlyArray).includes(key), - ); +export const readInspectRules = Effect.fn("inspect.report.readRules")(function* (workdir: string) { + const configValues = yield* CliConfigValues; + const resolvedConfig = yield* configValues.load({ workdir, projectRef: Option.none() }); + const rules = resolvedConfig.materialized.config.experimental.inspect?.rules ?? []; + const rawRules = getDocumentValue(resolvedConfig.loaded.document, [ + "experimental", + "inspect", + "rules", + ]); + + for (const [index, raw] of (Array.isArray(rawRules) ? rawRules : []).entries()) { + const unknownKeys = isDocumentRecord(raw) + ? Object.keys(raw).filter((key) => !RULE_FIELDS.includes(key)) + : []; if (unknownKeys.length > 0) { return yield* new DbConfigLoadError({ message: `failed to load config: experimental.inspect.rules[${index}] has unknown keys: ${unknownKeys.join(", ")}`, }); } - const fields: Record = {}; - for (const field of RULE_FIELDS) { - const coerced = coerceRuleField(record[field]); - if (coerced === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, - }); - } - fields[field] = yield* expandEnv(coerced); - } - rules.push({ - query: fields["query"]!, - name: fields["name"]!, - pass: fields["pass"]!, - fail: fields["fail"]!, - }); } - return rules as ReadonlyArray; + + return rules.map((rule): InspectRule => ({ + query: rule.query ?? "", + name: rule.name ?? "", + pass: rule.pass ?? "", + fail: rule.fail ?? "", + })); }); diff --git a/apps/cli/src/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/commands/inspect/report/report.config.unit.test.ts index b30ae5ca74..65cd6b40dd 100644 --- a/apps/cli/src/commands/inspect/report/report.config.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.config.unit.test.ts @@ -1,9 +1,8 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; -import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { readInspectRules } from "./report.config.ts"; const makeWorkdir = Effect.fnUntraced(function* ( @@ -25,33 +24,57 @@ const makeWorkdir = Effect.fnUntraced(function* ( return workdir; }); -const readRules = (configToml?: string, dotEnv?: string) => +const rule = (fail: string) => + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT 1"', + 'name = "r"', + 'pass = "ok"', + `fail = "${fail}"`, + "", + ].join("\n"); + +const causeText = (error: object) => { + const cause: unknown = Reflect.get(error, "cause"); + return cause instanceof Error ? cause.message : String(cause); +}; + +const readRules = (options: { + readonly configToml?: string; + readonly dotEnv?: string; + readonly env?: Readonly>; +}) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workdir = yield* makeWorkdir(fs, path, configToml, dotEnv); - return yield* readInspectRules(fs, path, workdir); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer))); + const workdir = yield* makeWorkdir(fs, path, options.configToml, options.dotEnv); + return yield* readInspectRules(workdir); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + configValuesLayer(options.env === undefined ? {} : { env: options.env }), + ), + ), + ); describe("readInspectRules", () => { it.effect("returns [] when config.toml is absent", () => Effect.gen(function* () { - const rules = yield* readRules(); - expect(rules).toEqual([]); + expect(yield* readRules({})).toEqual([]); }), ); it.effect("returns [] when there are no inspect rules", () => Effect.gen(function* () { - const rules = yield* readRules('project_id = "demo"\n'); - expect(rules).toEqual([]); + expect(yield* readRules({ configToml: 'project_id = "demo"\n' })).toEqual([]); }), ); it.effect("parses [experimental.inspect.rules]", () => Effect.gen(function* () { - const rules = yield* readRules( - [ + const rules = yield* readRules({ + configToml: [ "[[experimental.inspect.rules]]", 'query = "SELECT COUNT(*) FROM `locks.csv`"', 'name = "No locks"', @@ -59,160 +82,102 @@ describe("readInspectRules", () => { 'fail = "bad"', "", ].join("\n"), - ); + }); expect(rules).toEqual([ { query: "SELECT COUNT(*) FROM `locks.csv`", name: "No locks", pass: "ok", fail: "bad" }, ]); }), ); - it.effect("expands env(VAR) in rule string fields", () => + it.effect("fills a missing rule field with the empty string", () => Effect.gen(function* () { - const rules = yield* withEnvVar( - "REPORT_TEST_FAIL", - "from-env", - readRules( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT COUNT(*) FROM `locks.csv`"', - 'name = "r"', - 'pass = "ok"', - 'fail = "env(REPORT_TEST_FAIL)"', - "", - ].join("\n"), - ), - ); - expect(rules[0]?.fail).toBe("from-env"); + const rules = yield* readRules({ + configToml: '[[experimental.inspect.rules]]\nquery = "SELECT 1"\n', + }); + expect(rules).toEqual([{ query: "SELECT 1", name: "", pass: "", fail: "" }]); }), ); - it.effect( - "keeps the literal env(VAR) when the shell sets VAR empty, even if .env defines it", - () => - Effect.gen(function* () { - const read = readRules( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT 1"', - 'name = "r"', - 'pass = "ok"', - 'fail = "env(REPORT_TEST_X)"', - "", - ].join("\n"), - "REPORT_TEST_X=fromfile\n", - ); - const unset = yield* withEnvVar("REPORT_TEST_X", undefined, read); - expect(unset[0]?.fail).toBe("fromfile"); - const empty = yield* withEnvVar("REPORT_TEST_X", "", read); - expect(empty[0]?.fail).toBe("env(REPORT_TEST_X)"); - }), - ); - - it.effect("fails with DbConfigLoadError on a malformed config.toml", () => + it.effect("expands env(VAR) in rule string fields from the shell", () => Effect.gen(function* () { - const exit = yield* Effect.exit(readRules("this is = = not valid toml [[[")); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError"); - } + const rules = yield* readRules({ + configToml: rule("env(REPORT_TEST_FAIL)"), + env: { REPORT_TEST_FAIL: "from-env" }, + }); + expect(rules[0]?.fail).toBe("from-env"); }), ); - it.effect("weakly coerces scalar rule fields to strings", () => + it.effect("expands env(VAR) from the project .env when the shell leaves it unset", () => Effect.gen(function* () { - // Weakly-typed decoding: an int/bool field - // coerces to its string form (123 → "123", true → "1") rather than erroring. - const rules = yield* readRules( - [ - "[[experimental.inspect.rules]]", - "query = 123", - 'name = "r"', - "pass = true", - 'fail = "bad"', - "", - ].join("\n"), - ); - expect(rules[0]?.query).toBe("123"); - expect(rules[0]?.pass).toBe("1"); + const rules = yield* readRules({ + configToml: rule("env(REPORT_TEST_X)"), + dotEnv: "REPORT_TEST_X=fromfile\n", + }); + expect(rules[0]?.fail).toBe("fromfile"); }), ); - it.effect("fails when an inspect.rules entry is not a table", () => + it.effect("fails on a malformed config.toml", () => Effect.gen(function* () { - const exit = yield* Effect.exit( - readRules('[experimental.inspect]\nrules = ["not-a-table"]\n'), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("expected a table"); - } + const error = yield* Effect.flip(readRules({ configToml: "this is = = not valid toml [[[" })); + expect(error._tag).toBe("CliConfigParseError"); }), ); it.effect("rejects unknown keys in a rule table", () => Effect.gen(function* () { - const exit = yield* Effect.exit( - readRules( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT 1"', - 'name = "r"', - 'pass = "ok"', - 'fail = "bad"', - 'fails = "typo"', - "", - ].join("\n"), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("unknown keys: fails"); - } + const error = yield* Effect.flip(readRules({ configToml: `${rule("bad")}typo = "x"\n` })); + expect(error.message).toContain("unknown keys: typo"); }), ); - it.effect("accepts a single inline rules table as one rule", () => - Effect.gen(function* () { - const rules = yield* readRules( - [ - "[experimental.inspect.rules]", - 'query = "SELECT 1"', - 'name = "solo"', - 'pass = "ok"', - 'fail = "bad"', - "", - ].join("\n"), - ); - expect(rules).toEqual([{ query: "SELECT 1", name: "solo", pass: "ok", fail: "bad" }]); - }), + it.effect( + "fails on a single [experimental.inspect.rules] table instead of an array of tables", + () => + Effect.gen(function* () { + const error = yield* Effect.flip( + readRules({ + configToml: [ + "[experimental.inspect.rules]", + 'query = "SELECT 1"', + 'name = "solo"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), + }), + ); + expect(error._tag).toBe("CliConfigParseError"); + expect(causeText(error)).toContain('["experimental"]["inspect"]["rules"]'); + }), ); - it.effect("fails when rules is a scalar string", () => + it.effect("fails on a numeric or boolean rule field and names the field", () => Effect.gen(function* () { - const exit = yield* Effect.exit(readRules('[experimental.inspect]\nrules = "oops"\n')); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("expected a table"); - } + const numeric = yield* Effect.flip( + readRules({ + configToml: '[[experimental.inspect.rules]]\nquery = 123\nname = "r"\n', + }), + ); + const boolean = yield* Effect.flip( + readRules({ + configToml: + '[[experimental.inspect.rules]]\nquery = "SELECT 1"\nname = "r"\npass = true\n', + }), + ); + expect(numeric._tag).toBe("CliConfigParseError"); + expect(causeText(numeric)).toContain('["rules"][0]["query"]'); + expect(causeText(boolean)).toContain('["rules"][0]["pass"]'); }), ); - it.effect("fails when a rule field is a non-coercible type (nested table)", () => + it.effect("fails when an inspect.rules entry is not a table", () => Effect.gen(function* () { - const exit = yield* Effect.exit( - readRules( - [ - "[[experimental.inspect.rules]]", - "[experimental.inspect.rules.query]", - 'a = "b"', - "", - ].join("\n"), - ), + const error = yield* Effect.flip( + readRules({ configToml: '[experimental.inspect]\nrules = ["not-a-table"]\n' }), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("expected a string"); - } + expect(error._tag).toBe("CliConfigParseError"); }), ); }); diff --git a/apps/cli/src/commands/inspect/report/report.handler.ts b/apps/cli/src/commands/inspect/report/report.handler.ts index 16e4957977..7709c466c4 100644 --- a/apps/cli/src/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/commands/inspect/report/report.handler.ts @@ -77,7 +77,7 @@ const runInspectReport = Effect.fnUntraced(function* ( // Validated before any DB work so a malformed config aborts before connecting or writing // CSVs; applied later in the summary rendering below. - const configRules = yield* readInspectRules(fs, path, cliSettings.workdir); + const configRules = yield* readInspectRules(cliSettings.workdir); // `--linked` is the default, so absence of the others resolves to linked. const connType = target.connType ?? "linked"; diff --git a/apps/cli/src/commands/inspect/report/report.integration.test.ts b/apps/cli/src/commands/inspect/report/report.integration.test.ts index 690de2618b..9deae07f7d 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -19,7 +19,7 @@ import { withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; @@ -163,7 +163,7 @@ function setupReport(opts: SetupOpts = {}) { : mockRuntimeInfo({ cwd: opts.cwd }), mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), BunServices.layer, - cliConfigProviderLayer, + configValuesLayer(), ); return { layer, out, resolver, connection, telemetry }; } diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 5c5efd25f4..8ae9220bd6 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -67,6 +67,8 @@ All under `/supabase/.temp/` (plain text, created with parent dirs as n > unverified divergent cache is treated as untrustworthy. Similarly, when a name/UUID-resolved > branch link cannot REPLACE a divergent cache (write failure), the stale cache is deleted > rather than left trusted. +> +> `linked-project.json` is the only file touched on the branch/404 link path. ## API Routes @@ -90,8 +92,8 @@ Tenant service gateway (`https://.`, `apikey: ` + | `GET` | `/auth/v1/health` | best-effort | | `GET` | `/storage/v1/version` | best-effort | -> `link` does not probe `/config/database/postgres`, `/postgrest`, -> `/config/auth`, or `/network-restrictions`: they would only populate in-process +> Certain config probes (`/config/database/postgres`, `/postgrest`, +> `/config/auth`, `/network-restrictions`) are **omitted** here: they only populated in-process > config that standalone `link` discards, and they emit nothing observable. ## Environment Variables @@ -100,7 +102,7 @@ Tenant service gateway (`https://.`, `apikey: ` + | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_PROJECT_ID` | link-target resolution: `[ref-or-branch]` positional → `--project-ref` → env → TTY prompt (CLI-2167 adds the positional ahead of the flag). Also the 1st parent-project candidate for a branch-name lookup (CLI-2167). | | `SUPABASE_ACCESS_TOKEN` | Management API bearer auth (env → keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | bound to `--password`; **accepted but a no-op** for `link` (no DB-connection path consumes it) | +| `SUPABASE_DB_PASSWORD` | bound to the hidden `--password`; **accepted but ignored**: `link` never connects to the database, and an explicit `--password` prints a deprecation warning | ## Exit Codes @@ -126,7 +128,7 @@ Tenant service gateway (`https://.`, `apikey: ` + ### `--output-format text` -- stderr: `Selected project: ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. +- stderr: the warning `link ignores --password; remove it from your scripts.` when `--password` is passed (printed before the access token is resolved); `Selected project: ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. - stderr: `Resolved branch "" of project to project ref .` — via `output.raw(..., "stderr")` (NOT `output.info`, which clack renders on stdout with `│`/`◇` framing in text mode) — only when a non-ref-shaped `[ref-or-branch]`/`--project-ref` value @@ -150,5 +152,5 @@ in these modes (stderr in `json`; a structured `log` event in `stream-json`) rat **not** emitted: it requires loading the local `config.toml` `[db].major_version` with CLI defaults, which the CLI does not surface. The `postgres-version` file (the meaningful side effect) is still written. -- The `Finished supabase link.` line is emitted as **plain text**, with no ANSI color on `supabase link` (color - helpers are rendered plain); ANSI-stripping scripts are unaffected. +- The `Finished supabase link.` line is emitted as **plain text**, with no ANSI color + on `supabase link`. diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index a6d1294eff..48b07aecd9 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -1,8 +1,12 @@ -import { Option } from "effect"; +import { Effect, Option } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { Output } from "../../shared/output/output.service.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -21,11 +25,12 @@ const config = { ), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: "Password to your remote Postgres database.", + hidden: true, + }), skipPooler: Flag.Boolean("skip-pooler").pipe( Flag.withDescription("Use direct connection instead of pooler."), Flag.withDefault(false), @@ -48,6 +53,12 @@ export const linkHandler = (flags: LinkFlags) => withJsonErrorHandling, ); +const warnPasswordIgnored = Effect.fnUntraced(function* (flags: LinkFlags) { + if (Option.isNone(flags.password)) return; + const output = yield* Output; + yield* output.warn("link ignores --password; remove it from your scripts."); +}); + export const linkCommand = Command.make("link", config).pipe( Command.withDescription("Link to a Supabase project."), Command.withShortDescription("Link to a Supabase project"), @@ -63,4 +74,8 @@ export const linkCommand = Command.make("link", config).pipe( ]), Command.withHandler(linkHandler), Command.provide(managementApiRuntimeLayer(["link"])), + Command.provide(cliConfigValuesLayer), + // Outermost, so it runs before the management API runtime resolves the access token. + Command.provideEffectDiscard((flags) => warnPasswordIgnored(flags)), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/link/link.e2e.test.ts b/apps/cli/src/commands/link/link.e2e.test.ts index 41663385ea..7b8074fd70 100644 --- a/apps/cli/src/commands/link/link.e2e.test.ts +++ b/apps/cli/src/commands/link/link.e2e.test.ts @@ -25,4 +25,24 @@ describe("supabase link", () => { ), E2E_TIMEOUT_MS, ); + + it.live( + "warns that --password is ignored before the missing access token fails the command", + () => + withTempHome((home) => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["link", "--project-ref", "abcdefghijklmnopqrst", "--password", "hunter2"], + { home: home.dir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "link ignores --password; remove it from your scripts.", + ); + expect(stderr).toContain("Access token not provided"); + expect(`${stdout}${stderr}`).not.toContain("hunter2"); + }), + ), + E2E_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index 4fb7143a04..422e49c18e 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -3,6 +3,7 @@ import { Duration, Effect, FileSystem, Option, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; @@ -31,7 +32,7 @@ import { mapTenantApiKeysError } from "../../command-internal/get-tenant-api-key import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; import { linkServicesCore } from "../../command-internal/link-services-core.ts"; import { extractServiceKeys } from "../../command-internal/tenant-keys.ts"; -import { tempPaths } from "../../command-internal/temp-paths.ts"; +import { tempPaths } from "../../shared/config/temp-paths.ts"; import { LinkApiKeysNetworkError, LinkAuthTokenError, @@ -203,6 +204,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; + const configValues = yield* CliConfigValues; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -252,9 +254,11 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { const paths = tempPaths(path, cliSettings.workdir); const writeTempFile: WriteTempFile = (filePath, content) => - fs - .makeDirectory(path.dirname(filePath), { recursive: true }) - .pipe(Effect.andThen(() => fs.writeFileString(filePath, content))); + configValues.writeThrough( + fs + .makeDirectory(path.dirname(filePath), { recursive: true }) + .pipe(Effect.andThen(() => fs.writeFileString(filePath, content))), + ); // 1. Check remote project status (404 tolerated for branch projects). const project = yield* api.v1 @@ -298,12 +302,14 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { } // 3. Link services — best-effort, using the service-role key for tenant probes. - yield* linkServicesCore({ - ref, - serviceKey: serviceRole, - skipPooler: flags.skipPooler, - workdir: cliSettings.workdir, - }); + yield* configValues.writeThrough( + linkServicesCore({ + ref, + serviceKey: serviceRole, + skipPooler: flags.skipPooler, + workdir: cliSettings.workdir, + }), + ); // 4. Save project ref (mandatory — a write failure fails the command). yield* writeTempFile(paths.projectRef, ref); diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index d03df9e497..a7466b5d41 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -13,6 +13,7 @@ import { mockContextualAnalytics, mockOutput, } from "../../../tests/helpers/mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { VALID_REF, buildTestRuntime, @@ -238,14 +239,17 @@ function setup(opts: SetupOpts = {}) { workdir: tempRoot.current, projectId: opts.projectId ?? Option.none(), }); - const layer = buildTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, - cliSettings, - analytics, - telemetry: telemetry.layer, - linkedProjectCache: linkedCache.layer, - }); + const layer = Layer.merge( + buildTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, + cliSettings, + analytics, + telemetry: telemetry.layer, + linkedProjectCache: linkedCache.layer, + }), + cliConfigValuesTestLayer, + ); return { layer, out, analytics, telemetry, linkedCache, apiMock, workdir: tempRoot.current }; } @@ -603,11 +607,14 @@ describe("link integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({ tenant: "fail" }) }, - cliSettings, - }); + const layer = Layer.merge( + buildTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({ tenant: "fail" }) }, + cliSettings, + }), + cliConfigValuesTestLayer, + ); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1458,6 +1465,7 @@ describe("link integration", () => { cliSettings, analytics, }), + cliConfigValuesTestLayer, commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", VALID_REF]), @@ -1487,6 +1495,7 @@ describe("link integration", () => { cliSettings, analytics, }), + cliConfigValuesTestLayer, commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", "my-branch"]), diff --git a/apps/cli/src/commands/login/login-claude-hint.ts b/apps/cli/src/commands/login/login-claude-hint.ts index ccbc6d0d89..7afc2c1c4a 100644 --- a/apps/cli/src/commands/login/login-claude-hint.ts +++ b/apps/cli/src/commands/login/login-claude-hint.ts @@ -1,10 +1,12 @@ +import { ambientEnvironment } from "../../shared/config/cli-config-provider.layer.ts"; + /** * Returns the Claude Code plugin-install hint only when the CLI is running inside Claude Code * (`CLAUDECODE`/`CLAUDE_CODE` env) and stdout is an interactive terminal; otherwise returns `""`. */ const CLAUDE_CODE_HINT = ``; -export function isClaudeCode(env: NodeJS.ProcessEnv = process.env): boolean { +export function isClaudeCode(env: NodeJS.ProcessEnv = ambientEnvironment()): boolean { return (env["CLAUDECODE"] ?? "") !== "" || (env["CLAUDE_CODE"] ?? "") !== ""; } diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 4be9d6750b..b346fe4392 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -5,6 +5,8 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationDown } from "./down.handler.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { // `--last` is conceptually a uint (default 1); Effect has no uint type, so negatives are @@ -63,4 +65,6 @@ export const migrationDownCommand = Command.make("down", config).pipe( ), ), Command.provide(migrationDbRuntimeLayer(["migration", "down"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index 312183c4aa..46d7233447 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -10,7 +10,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua, bold, yellow } from "../../../command-internal/colors.ts"; -import { loadProjectEnv, readDbToml } from "../../../command-internal/db-config.toml-read.ts"; +import { loadProjectEnvValues, readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; @@ -73,7 +73,7 @@ const runDown = Effect.fnUntraced(function* ( // Loads after the flag-group check above, so a flag conflict surfaces before // any .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const downFlow = Effect.gen(function* () { diff --git a/apps/cli/src/commands/migration/down/down.integration.test.ts b/apps/cli/src/commands/migration/down/down.integration.test.ts index d09de54d48..1de0355a97 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -12,6 +12,7 @@ import { useTempWorkdir, sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; @@ -123,6 +124,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, out.layer, telemetry.layer, cache.layer, diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 70ab5fb5d0..c211abea9f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -5,6 +5,8 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationFetch } from "./fetch.handler.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -48,4 +50,6 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( ), ), Command.provide(migrationDbRuntimeLayer(["migration", "fetch"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index b479e0cc7a..952dce73dd 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -22,10 +22,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked database connection (`--password`/`-p` takes precedence) | no | ## Exit Codes @@ -33,6 +33,7 @@ | ---- | ------------------------------------------------------------------------ | | `0` | success | | `1` | database connection failure | +| `1` | `--password` with `--db-url` or `--local` | | `1` | failed to open migrations directory | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | @@ -58,11 +59,15 @@ Same structured `migrations` result delivered as an NDJSON `result` event. - `--linked` (default true) lists migrations from the linked project via direct DB connection. - `--local` lists migrations applied to the local database. - `--db-url` targets a specific database URL directly. -- `--password` / `-p` sets the DB password (also reads `DB_PASSWORD` env var). +- **`--password`** is rejected with `--db-url` (`--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`) and with `--local` (`--password can't be used with --local. The local database uses [db].password from supabase/config.toml.`), + exit 1. For `--linked` the password resolves as flag > shell `SUPABASE_DB_PASSWORD` > project + `.env*` > config; the env value is withheld when the target differs from `.temp/project-ref` + (stderr `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .`), and a + temporary login role is minted instead (ADR 0031). - `--db-url`, `--linked`, and `--local` are mutually exclusive. - **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded flag (deliberately stricter than - `SUPABASE_PROJECT_ID`, which is simply left unused on + `SUPABASE_PROJECT_ID`, which is simply unused on a non-linked target). diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index 6a790a3cdd..7d3c1caf4b 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -1,10 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationList } from "./list.handler.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -26,11 +29,12 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; export type MigrationListFlags = CliCommand.Command.Config.Infer; @@ -56,4 +60,6 @@ export const migrationListCommand = Command.make("list", config).pipe( ), ), Command.provide(migrationDbRuntimeLayer(["migration", "list"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/list/list.handler.ts b/apps/cli/src/commands/migration/list/list.handler.ts index 308e64ea7b..4543b6765b 100644 --- a/apps/cli/src/commands/migration/list/list.handler.ts +++ b/apps/cli/src/commands/migration/list/list.handler.ts @@ -8,14 +8,17 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; +import { + rejectPasswordWithDirectTarget, + resolveDbTargetFlags, +} from "../../../command-internal/db-target-flags.ts"; import { listRemoteMigrations, loadLocalVersions, } from "../../../command-internal/migration-history.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { MigrationPasswordFlagsError, MigrationTargetFlagsError } from "../migration.errors.ts"; +import { MigrationTargetFlagsError } from "../migration.errors.ts"; import type { MigrationListFlags } from "./list.command.ts"; import { makeMigrationListRows, migrationListTableCells } from "./list.format.ts"; @@ -40,12 +43,7 @@ const runList = Effect.fnUntraced(function* ( message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, }); } - if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* new MigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }); - } + yield* rejectPasswordWithDirectTarget(target.connType ?? "linked", flags.password); // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target; see push.handler.ts's identical guard. diff --git a/apps/cli/src/commands/migration/list/list.integration.test.ts b/apps/cli/src/commands/migration/list/list.integration.test.ts index bdf87b9e28..9197d1b00e 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -250,7 +250,21 @@ describe("migration list", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationPasswordFlagsError"); + expect(Option.isSome(failure) && failure.value._tag).toBe("DbPasswordFlagsError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --local combined with --password", () => { + const { layer } = setup(tmp.current, { args: ["--local"] }); + return Effect.gen(function* () { + const exit = yield* migrationList( + flags({ local: true, linked: false, password: Option.some("pw") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("DbPasswordFlagsError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/migration/migration.errors.ts b/apps/cli/src/commands/migration/migration.errors.ts index 5aebd87526..33082fa6a0 100644 --- a/apps/cli/src/commands/migration/migration.errors.ts +++ b/apps/cli/src/commands/migration/migration.errors.ts @@ -19,18 +19,6 @@ export class MigrationTargetFlagsError extends Data.TaggedError("MigrationTarget } } -/** - * `--db-url` combined with `--password`/`-p`. Matches the established - * mutually-exclusive-flags error text for `db-url`/`password` (list / repair / squash). - */ -export class MigrationPasswordFlagsError extends Data.TaggedError("MigrationPasswordFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * A positional version argument is not a valid integer. Matches the established * `failed to parse : invalid version number` text. diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index b13a3180d2..bfc555c5de 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -21,10 +21,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked database connection (`--password`/`-p` takes precedence) | no | ## Exit Codes @@ -32,6 +32,7 @@ | ---- | ------------------------------------------------------------------------ | | `0` | success | | `1` | database connection failure | +| `1` | `--password` with `--db-url` or `--local` | | `1` | invalid or missing `--status` flag | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | @@ -48,11 +49,10 @@ provisioned remote runs no provisioning DDL — supabase/cli#6393) followed by o repair transaction: (for repair-all) `TRUNCATE`, plus `applied` → per-version `UPSERT` from the local file, `reverted` → `DELETE ... WHERE version = ANY($1)`. -> **Atomicity note:** the TRUNCATE/UPSERT/DELETE statements run inside an explicit +> **Atomicity note:** the TRUNCATE/UPSERT/DELETE statements run in an explicit > `BEGIN`/`COMMIT` with `ROLLBACK` on error, so a partial failure (e.g. TRUNCATE -> succeeds but a later UPSERT fails) leaves the history table unchanged. This -> handler keeps the transaction instead of using the migration apply path's batch -> primitive. +> succeeds but a later UPSERT fails) leaves the table unchanged. This handler keeps +> that transaction instead of using the migration apply path's batch primitive. ### `--output-format json` @@ -77,11 +77,15 @@ migration history table to match local migration files?` (default **NO**). repair-all. - In `applied` mode, reads the matching `supabase/migrations/_*.sql` file for the name + statements; a missing file exits non-zero. -- `--linked` (default true), `--local`, and `--db-url` are mutually exclusive, as - are `--db-url` and `--password`/`-p`. +- `--linked` (default true), `--local`, and `--db-url` are mutually exclusive. +- **`--password`** is rejected with `--db-url` (`--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`) and with `--local` (`--password can't be used with --local. The local database uses [db].password from supabase/config.toml.`), + exit 1. For `--linked` the password resolves as flag > shell `SUPABASE_DB_PASSWORD` > project + `.env*` > config; the env value is withheld when the target differs from `.temp/project-ref` + (stderr `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .`), and a + temporary login role is minted instead (ADR 0031). - **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded flag (deliberately stricter than - `SUPABASE_PROJECT_ID`, which is simply left unused on + `SUPABASE_PROJECT_ID`, which is simply unused on a non-linked target). diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index f69faa7014..44e020b86c 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -1,9 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationRepair } from "./repair.handler.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; const config = { versions: Argument.String("version").pipe( @@ -32,11 +35,12 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), } as const; export const migrationRepairCommand = Command.make("repair", config).pipe( @@ -71,4 +75,6 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( ), ), Command.provide(migrationDbRuntimeLayer(["migration", "repair"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 530168638f..6872ce73dd 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -11,10 +11,13 @@ import { Output } from "../../../shared/output/output.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua } from "../../../command-internal/colors.ts"; -import { loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; +import { loadProjectEnvValues } from "../../../command-internal/db-config.toml-read.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection, type DbSession } from "../../../command-internal/db-connection.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; +import { + rejectPasswordWithDirectTarget, + resolveDbTargetFlags, +} from "../../../command-internal/db-target-flags.ts"; import { DELETE_MIGRATION_VERSION, type MigrationFile, @@ -31,7 +34,6 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { MigrationFileNotFoundError, MigrationInvalidVersionError, - MigrationPasswordFlagsError, MigrationTargetFlagsError, OperationCanceledError, } from "../migration.errors.ts"; @@ -124,12 +126,7 @@ const runRepair = Effect.fnUntraced(function* ( message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, }); } - if (Option.isSome(input.dbUrl) && Option.isSome(input.password)) { - return yield* new MigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }); - } + yield* rejectPasswordWithDirectTarget(target.connType ?? "linked", input.password); const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const repairAll = input.versions.length === 0; @@ -163,7 +160,7 @@ const runRepair = Effect.fnUntraced(function* ( // Loads after the flag-group check above, so a flag conflict surfaces before any // .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms the // repair-all prompt. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const repairFlow = Effect.gen(function* () { diff --git a/apps/cli/src/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/commands/migration/repair/repair.integration.test.ts index 45e6c82708..c43073ac89 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -432,6 +432,26 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); + it.live("rejects --password combined with --local", () => { + const { layer } = setup(tmp.current, { args: ["--local"] }); + return Effect.gen(function* () { + const exit = yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + local: true, + linked: false, + password: Option.some("pw"), + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("DbPasswordFlagsError"); + } + }).pipe(Effect.provide(layer)); + }); + it.live("repairs the project given via --project-ref, overriding the default linked ref", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive // the cached ref. diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 89a4d36f2e..a07f8b3b66 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -84,7 +84,7 @@ live shadow available. ## Environment Variables -`SUPABASE_YES`, `DB_PASSWORD`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, +`SUPABASE_YES`, `SUPABASE_DB_PASSWORD` (`--linked` only), `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, `DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, `SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_USE_SLIM_IMAGES` (current-pin shadow Postgres and PG15+ realtime/storage/auth migrate-job images → slim `ghcr.io/supabase/cli`; historical pins, PG14, OrioleDB tags the catalog does not pin, flag-off `15.8.1.085` stay on docker.io), `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, `SUPABASE_EXPERIMENTAL`, `SUPABASE_SHADOW_CACHE` (stack shadow baseline cache; on by default, falsy disables restore and publication). @@ -96,6 +96,7 @@ live shadow available. | `0` | success — **including** the single-migration no-op **and** a declined remote-baseline prompt | | `1` | invalid `--version`; `--version` file not found; `version not found`; migrations-dir read failure; shadow create/health/setup/apply failure; `pg_dump` non-zero exit; migration-file open/write failure; baseline connect/batch failure; flag-group conflicts | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `1` | `--password` with `--db-url` or `--local` | | `130` | SIGINT | ## Output @@ -142,8 +143,12 @@ code or the rest of the payload. ## Notes -- `--local` defaults **true**; `[db-url linked local]` and - `[db-url password]` are the two mutually-exclusive flag groups. +- `--local` defaults **true**; `[db-url linked local]` is the mutually-exclusive target group. +- **`--password`** is rejected with `--db-url` (`--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`), with `--local` (`--password can't be used with --local. The local database uses [db].password from supabase/config.toml.`), and when the target defaulted to local (`migration squash targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.`), + exit 1. For `--linked` the password resolves as flag > shell `SUPABASE_DB_PASSWORD` > project + `.env*` > config; the env value is withheld when the target differs from `.temp/project-ref` + (stderr `Not sending SUPABASE_DB_PASSWORD to : this directory is linked to . Using a temporary login role instead (needs supabase login or SUPABASE_ACCESS_TOKEN). Pass --password to use a password for .`), and a + temporary login role is minted instead (ADR 0031). - **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather @@ -161,14 +166,14 @@ code or the rest of the payload. answer) is a **success** path (exit 0, no baseline query, `Finished …` still prints) — the opposite of `migration repair`/`fetch`/ `down`, which treat a decline as a cancellation. -- **Atomicity note:** the baseline `DELETE`/`INSERT` run inside an explicit `BEGIN`/`COMMIT` - with `ROLLBACK` on error (like `migration repair`), so a partial failure cannot leave the - DELETE applied without the INSERT. -- **Diff-writing edge cases:** (a) `lineByLineDiff`'s output is never truncated, even when a - single dumped line exceeds 64 KiB (`squash.diff.ts`); (b) the separator comment and the - auth/storage diff are combined into one write, so a hypothetical failure isolated to just the - separator bytes surfaces as `failed to write line: …`; not realistically triggerable on a real - filesystem for a single already-open file descriptor. +- **Atomicity note:** the baseline `DELETE`/`INSERT` run in an explicit `BEGIN`/`COMMIT` with + `ROLLBACK` on error, so a partial failure cannot leave the DELETE applied without the INSERT + (as in `migration repair`). +- **Diff output handling:** (a) a dumped line longer than 64 KiB is written in full, not + truncated (`squash.diff.ts`); (b) the separator comment and the auth/storage diff are + combined into one write, so a failure isolated to just the separator bytes surfaces as + `failed to write line: …`; this is not realistically triggerable on a real filesystem for a + single already-open file descriptor. - `Initialising schema...` is printed by the shared setup prelude just before `setupDatabase` runs rather than from inside it — inherited from CLI-1956, shared with `db diff`/`db pull`'s identical shadow-provisioning prelude. diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 056ad3e713..537cb9822a 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -1,6 +1,9 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationSquashRuntimeLayer } from "../migration.layers.ts"; @@ -25,11 +28,12 @@ const config = { Flag.withDescription("Squashes the migration history of the local database."), Flag.withDefault(true), ), - password: Flag.String("password").pipe( - Flag.withAlias("p"), - Flag.withDescription("Password to your remote Postgres database."), - Flag.optional, - ), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: + "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + }), // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.String("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), @@ -63,4 +67,6 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( ), ), Command.provide(migrationSquashRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 2e909e53d7..00c73998b1 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -33,14 +33,17 @@ import { } from "../../../command-internal/db-bootstrap/shadow-database.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { - loadProjectEnv, + loadProjectEnvValues, readDbToml, type DbTomlValues, } from "../../../command-internal/db-config.toml-read.ts"; import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { + rejectPasswordWithDirectTarget, + resolveDbTargetFlags, +} from "../../../command-internal/db-target-flags.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { errorMessage, relativizeErrorMessage } from "../../../command-internal/error-message.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackWithShadowDatabase } from "../../../command-internal/stack-shadow.ts"; @@ -62,7 +65,6 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { MigrationFileNotFoundError, MigrationInvalidVersionError, - MigrationPasswordFlagsError, MigrationTargetFlagsError, } from "../migration.errors.ts"; import { migrationConfirm } from "../migration.prompt.ts"; @@ -478,11 +480,11 @@ const runSquash = Effect.fnUntraced(function* ( message: mutuallyExclusiveFlagsMessage(["db-url", "linked", "local"], target.setFlags), }); } - if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* new MigrationPasswordFlagsError({ - message: mutuallyExclusiveFlagsMessage(["db-url", "password"], ["db-url", "password"]), - }); - } + yield* rejectPasswordWithDirectTarget( + target.connType ?? "local", + flags.password, + target.connType === undefined ? { localByDefaultFor: "migration squash" } : {}, + ); const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const connType = target.connType ?? "local"; @@ -519,7 +521,6 @@ const runSquash = Effect.fnUntraced(function* ( runtimeInfo.platform, debug, connType === "linked" ? linkedRef : undefined, - toml.remoteOverrideKeys, ); // The resolver owns --password/DB_PASSWORD/temp-login-role/IPv6 handling for @@ -540,7 +541,7 @@ const runSquash = Effect.fnUntraced(function* ( // Loads after the flag-group check above, so a flag conflict surfaces before any // .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms the // remote-baseline prompt. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); // Runs after DB-config resolution, so an invalid target surfaces first. diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index bf00a82ba0..3551c04328 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1,3 +1,4 @@ +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -60,7 +61,7 @@ import { type DbSession, type PgConnInput, } from "../../../command-internal/db-connection.service.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; @@ -346,6 +347,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // Listed first so every fake service layer below overrides it; Layer.mergeAll is // last-wins on a shared service. BunServices.layer, + cliConfigValuesTestLayer, out.layer, telemetry.layer, cache.layer, @@ -488,11 +490,11 @@ describe("migration squash", () => { const exit = yield* migrationSquash( flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationPasswordFlagsError"); + expect(failureTag(exit)).toBe("DbPasswordFlagsError"); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres", ); } }).pipe(Effect.provide(s.layer)); diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index 0049071ee0..a92ade3e41 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -8,6 +8,7 @@ import { mockLinkedProjectCacheTracked, mockTelemetryStateTracked, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo, mockStdin } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -19,7 +20,7 @@ import { } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; @@ -97,6 +98,7 @@ const testLayer = (root: string) => dbConnectionLayer, stackCatalogSetupLayer, stackBackendLayer("stack"), + cliConfigValuesTestLayer, ).pipe(Layer.provideMerge(BunServices.layer)); describe("managed migration squash", { timeout: 180_000 }, () => { diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index e0c86b4bb5..66b8ba9c44 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -5,6 +5,8 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationUp } from "./up.handler.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; const config = { includeAll: Flag.Boolean("include-all").pipe( @@ -53,4 +55,6 @@ export const migrationUpCommand = Command.make("up", config).pipe( ), ), Command.provide(migrationDbRuntimeLayer(["migration", "up"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/migration/up/up.integration.test.ts b/apps/cli/src/commands/migration/up/up.integration.test.ts index 9edbb425fc..c10fca6de6 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -11,6 +11,7 @@ import { useTempWorkdir, sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; @@ -107,6 +108,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, out.layer, telemetry.layer, cache.layer, diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 27262d715a..04d7703b5f 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -3,11 +3,13 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { pull } from "./pull.handler.ts"; import { pullRuntimeLayer } from "./pull.layers.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; const config = { // Accepts either a project ref or a branch name of the linked project — no separate `--target`. @@ -84,4 +86,6 @@ export const pullCommand = Command.make("pull", config).pipe( ]), Command.withHandler(pullHandler), Command.provide(pullRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index be5e970f88..dbf13f0d57 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -26,6 +26,7 @@ import { mockStdin, mockTty, } from "../../../tests/helpers/mocks.ts"; +import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; import { buildTestRuntime, VALID_REF, @@ -649,6 +650,7 @@ function setup(opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( + configValuesLayer({ output: finalOutputLayer }), buildTestRuntime({ out: { layer: finalOutputLayer }, api, diff --git a/apps/cli/src/commands/pull/pull.layers.ts b/apps/cli/src/commands/pull/pull.layers.ts index 06ef3a1543..691423306a 100644 --- a/apps/cli/src/commands/pull/pull.layers.ts +++ b/apps/cli/src/commands/pull/pull.layers.ts @@ -7,7 +7,7 @@ import { CommandSettings } from "../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; import { DbConfigResolver } from "../../command-internal/db-config.service.ts"; import { DbConnection } from "../../command-internal/db-connection.service.ts"; -import { DebugLogger } from "../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { DockerRun } from "../../command-internal/docker-run.service.ts"; import { IdentityStitch, identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { managementApiRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index e738967f52..a2c0e947ff 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -26,12 +26,12 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index 24801e09a5..5af84fe312 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -2,17 +2,16 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadCliConfig`; a parse failure is logged to the debug logger and tolerated, not fatal | -| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | -| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | -| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | +| Path | Format | When | +| ------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/config.json` or `config.toml` | JSON / TOML | always (for `[edge_runtime.secrets]`, `config.json` preferred) — via the `CliConfigValues` resolved config, with a matching `[remotes.*]` block applied; a parse failure is logged to the debug logger and tolerated, not fatal | +| `/.env*`, `/supabase/.env*` | dotenv | always — project env files selected by `SUPABASE_ENV` (default `development`), context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values; a variable the shell sets is never taken from a file | +| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written @@ -30,13 +29,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | -| `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim. | — | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | +| `env(VAR)` references | values matching `env(NAME)` in `[edge_runtime.secrets]` are resolved against the loaded env. Missing variables preserve the literal verbatim. | — | ## Exit Codes @@ -80,6 +79,6 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. -- `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadCliConfig` + `resolveCliConfigSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler (secrets whose value never resolved past the literal `env(VAR)` form are dropped). +- `[edge_runtime.secrets]` from config.toml is read through the `CliConfigValues` resolved config (shell env > project `.env*` > config for `env(VAR)` references) + `resolveCliSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler (secrets whose value never resolved past the literal `env(VAR)` form are dropped). - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/set/set.command.ts b/apps/cli/src/commands/secrets/set/set.command.ts index 2b42e12df1..fca3bd1391 100644 --- a/apps/cli/src/commands/secrets/set/set.command.ts +++ b/apps/cli/src/commands/secrets/set/set.command.ts @@ -1,6 +1,8 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -40,4 +42,6 @@ export const secretsSetCommand = Command.make("set", config).pipe( secretsSet(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["secrets", "set"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 818ecc6eaa..45c2ad95ac 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -1,20 +1,18 @@ -import { - loadCliProjectEnvironment, - CliConfigSchema, - type CliConfig, - type CliConfigParseError, -} from "@supabase/config/effect"; -import { - loadCliConfig, - resolveCliConfigSubtree, -} from "../../../command-internal/cli-config-load.ts"; +import { CliConfigSchema, type CliConfig } from "@supabase/config/effect"; import { V1BulkCreateSecretsInput } from "@supabase/api/effect"; import { parse as parseDotenv } from "dotenv"; import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { envReferenceNames } from "../../../config/cli-config-document.ts"; +import { resolveCliSubtree } from "../../../config/cli-config-subtree.ts"; +import { + CliConfigValues, + type ResolvedCliConfig, +} from "../../../config/cli-config-values.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -53,8 +51,8 @@ function isRecord(value: unknown): value is Record { * otherwise-valid secret. `Schema.decodeUnknownSync` has no such tolerance, so this re-slices * `edge_runtime.secrets` out of the pre-decode document and decodes each entry independently. */ -function recoverEdgeRuntimeConfig(cause: CliConfigParseError): CliConfig | null { - if (cause.document === undefined) { +function recoverEdgeRuntimeConfig(cause: { readonly document?: unknown }): CliConfig | null { + if (!isRecord(cause.document)) { return null; } const edgeRuntime = cause.document.edge_runtime; @@ -93,6 +91,14 @@ function filterDecodableSecrets(secrets: Record): Record`, + // sent: `resolveCliSubtree` wraps every resolved secret leaf in `Redacted`, // while unresolved `env(VAR)` references stay plain strings, so `Redacted.isRedacted` // distinguishes them. const merged = new Map(); // A malformed config.toml (or sibling .env/.env.local) is swallowed here (logged, not // fatal) and proceeds with empty config-sourced secrets — env-file and positional-arg - // secrets still work. + // secrets still work. An invalid value in an unrelated key is left out of the resolved config + // rather than failing it. // // Passing `ref` merges a matching `[remotes.*]` block over the base config before decode, // so a schema-decode error on a remote target recovers that remote's override, not the base - // document. The CLI loader's duplicate-project_id/format checks are what make the - // `DuplicateRemoteProjectIdError` catch below fire. - const loadedConfig = yield* loadCliConfig(runtimeInfo.cwd, { - projectRef: ref, - }).pipe( - Effect.flatMap((loaded) => { - if (loaded === null) { - return Effect.succeed(null); - } - // Printed unconditionally as soon as a matching `[remotes.*]` block is found, ahead of - // the (possibly failing) decode — other handlers surface this the same way, so this - // path must not silently drop it. - return ( - loaded.appliedRemote !== undefined - ? output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr") - : Effect.void - ).pipe(Effect.as(loaded.config)); - }), - Effect.catchTags({ - CliConfigParseError: (cause) => { - // `smol-toml` embeds a source codeblock (which can include real secret values) after a - // blank-line separator on a raw parse failure; truncate before it. A schema-decode - // error puts the rejected value inline instead, with no such separator, so use a fixed, - // content-free message there. - const shortMessage = - cause.document === undefined - ? String(cause.cause).split("\n\n")[0] - : "schema validation failed"; - // Printed here too since a matching `[remotes.*]` block is found before decode runs, - // even though decode then failed. Emitted ahead of the debug log below to preserve - // that order. - return ( - cause.appliedRemote !== undefined - ? output.raw(`Loading config override: [remotes.${cause.appliedRemote}]\n`, "stderr") - : Effect.void - ).pipe( + // document. + const reportRemote = (appliedRemote: string | undefined) => + appliedRemote === undefined + ? Effect.void + : output.raw(`Loading config override: [remotes.${appliedRemote}]\n`, "stderr"); + const secretSource = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.some(ref), + tolerateInvalid: true, + }) + .pipe( + Effect.flatMap((loadedResolvedConfig) => + // Printed unconditionally as soon as a matching `[remotes.*]` block is found, ahead of + // the (possibly failing) decode — other handlers surface this the same way, so this + // path must not silently drop it. + reportRemote(Option.getOrUndefined(loadedResolvedConfig.appliedRemote)).pipe( Effect.andThen( - debugLogger.debug(`failed to parse supabase/config.toml: ${shortMessage}`), + loadedResolvedConfig.invalid.length === 0 + ? Effect.void + : debugLogger.debug( + "failed to parse supabase/config.toml: schema validation failed", + ), ), - Effect.as(recoverEdgeRuntimeConfig(cause)), - ); - }, - // A malformed dotenv line fails with this distinct tag (env resolution runs before - // schema decode), so there's no parsed document to recover a subtree from — recover to - // `null`, not `recoverEdgeRuntimeConfig`. - CliProjectEnvParseError: (cause) => - debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), - // Two `[remotes.*]` blocks declaring the same `project_id` as `ref`; swallowed - // non-fatally like every other load error here. - DuplicateRemoteProjectIdError: (cause) => - debugLogger.debug(cause.message).pipe(Effect.as(null)), - // A `[remotes.*]` block's `project_id` fails the ref-pattern check; swallowed the same - // non-fatal way, so a malformed remote block must not abort an otherwise-valid - // `secrets set`. - InvalidRemoteProjectIdError: (cause) => - debugLogger.debug(cause.message).pipe(Effect.as(null)), - }), - ); - if (loadedConfig !== null) { - const projectEnv = yield* loadCliProjectEnvironment({ - cwd: runtimeInfo.cwd, - baseEnv: process.env, - }); - if (projectEnv !== null) { - const resolved = yield* resolveCliConfigSubtree( - loadedConfig.edge_runtime, - projectEnv, - "edge_runtime", - ); - for (const [name, value] of Object.entries(resolved.secrets ?? {})) { - // An empty `[edge_runtime.secrets]` value is skipped rather than sent as an - // empty-string overwrite of a remote secret. This applies to config-sourced secrets - // only — an explicit `--env-file`/positional `NAME=` below is sent as-is regardless - // of value. - if (Redacted.isRedacted(value) && Redacted.value(value).length > 0) { - merged.set(name, Redacted.value(value)); - } + Effect.as({ + edgeRuntime: loadedResolvedConfig.materialized.config.edge_runtime, + resolvedConfig: loadedResolvedConfig, + }), + ), + ), + Effect.catchTags({ + CliConfigParseError: (cause) => { + // `smol-toml` embeds a source codeblock (which can include real secret values) after a + // blank-line separator on a raw parse failure; truncate before it. A schema-decode + // error puts the rejected value inline instead, with no such separator, so use a fixed, + // content-free message there. + const shortMessage = + cause.document === undefined + ? String(cause.cause).split("\n\n")[0] + : "schema validation failed"; + // Printed here too since a matching `[remotes.*]` block is found before decode runs, + // even though decode then failed. Emitted ahead of the debug log below to preserve + // that order. + return reportRemote(cause.appliedRemote).pipe( + Effect.andThen( + debugLogger.debug(`failed to parse supabase/config.toml: ${shortMessage}`), + ), + Effect.as({ + edgeRuntime: recoverEdgeRuntimeConfig(cause)?.edge_runtime, + resolvedConfig: undefined, + }), + ); + }, + // A malformed dotenv file or `[remotes.*]` block (duplicate or malformed `project_id`) + // has no parsed document to recover a subtree from — recover to `undefined`, not + // `recoverEdgeRuntimeConfig`. + CliConfigLoadError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(noConfigSecrets)), + DuplicateRemoteProjectIdError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(noConfigSecrets)), + InvalidRemoteProjectIdError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(noConfigSecrets)), + ProjectRefReadError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(noConfigSecrets)), + }), + ); + const { edgeRuntime } = secretSource; + if (edgeRuntime !== undefined) { + const envResolvedConfig = + secretSource.resolvedConfig ?? + (yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.some(ref), + ignoreConfigFile: true, + tolerateUnreadableLinkedRef: true, + }) + .pipe(Effect.orElseSucceed(() => undefined))); + const values = + envResolvedConfig === undefined + ? {} + : yield* envResolvedConfig.envValues(envReferenceNames(edgeRuntime)).pipe(Effect.orDie); + const resolved = yield* resolveCliSubtree(edgeRuntime, values, "edge_runtime"); + const secrets = isRecord(resolved) ? resolved["secrets"] : undefined; + for (const [name, value] of Object.entries(isRecord(secrets) ? secrets : {})) { + // An empty `[edge_runtime.secrets]` value is skipped rather than sent as an + // empty-string overwrite of a remote secret. This applies to config-sourced secrets + // only — an explicit `--env-file`/positional `NAME=` below is sent as-is regardless + // of value. + if (Redacted.isRedacted(value) && Redacted.value(value).length > 0) { + merged.set(name, Redacted.value(value)); } } } diff --git a/apps/cli/src/commands/secrets/set/set.integration.test.ts b/apps/cli/src/commands/secrets/set/set.integration.test.ts index e8b7593f6f..8fe95146f5 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -2,6 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -10,7 +11,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { secretsSet } from "./set.handler.ts"; @@ -80,6 +81,7 @@ function setup(opts: SetupOpts = {}) { }), mockRuntimeInfo({ cwd: tempRoot.current }), processEnvLayer(opts.env ?? {}), + cliConfigValuesTestLayer, debugLogger.layer, ); return { layer, out, api, debugLogger }; @@ -560,7 +562,7 @@ FROM_CONFIG = "config-value" it.live( "tolerates a malformed supabase/.env, logs it to the debug logger, and still sets CLI-arg secrets", () => { - // `loadCliConfig` resolves `env(VAR)` references against `.env`/`.env.local` before + // The resolved config load resolves `env(VAR)` references against `.env`/`.env.local` before // schema decode, so a malformed dotenv line fails with `CliProjectEnvParseError` rather // than `CliConfigParseError`, and this must not abort the command either. `.env` is only // read once a config.toml/.json is found, so one must exist here too. @@ -724,6 +726,37 @@ FROM_CONFIG = "base-value" [remotes.staging] project_id = "${VALID_REF}" +[remotes.staging.edge_runtime.secrets] +FROM_CONFIG = "remote-value" +`, + ); + yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: [], + }); + expect(parsePostBody(api.requests[0]?.body)).toEqual([ + { name: "FROM_CONFIG", value: "remote-value" }, + ]); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "selects the [remotes.*] block named by SUPABASE_REMOTES__PROJECT_ID and uses its secrets", + () => { + const { layer, out, api } = setup({ + env: { SUPABASE_REMOTES_STAGING_PROJECT_ID: VALID_REF }, + }); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "base-value" + +[remotes.staging] +project_id = "aaaaaaaaaaaaaaaaaaaa" + [remotes.staging.edge_runtime.secrets] FROM_CONFIG = "remote-value" `, diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 23a3ad2cbf..68023dc210 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -27,12 +27,12 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | -| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no (walks up from CWD looking for `supabase/config.toml` or `supabase/config.json`) | ## Exit Codes diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 507894627d..804bc89836 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -34,7 +34,7 @@ credential, capability-state, and error behavior. `db reset --local`'s bucket se ### Storage gateway routes (local and remote) **Local (legacy backend):** `api.external_url` (default `http://:54321`, where `` resolves as: -`SUPABASE_SERVICES_HOSTNAME` → TCP `DOCKER_HOST` → `127.0.0.1`). The +`SUPABASE_SERVICES_HOSTNAME` (shell env, then project `.env*`) → TCP shell `DOCKER_HOST` → `127.0.0.1`). The `api.{enabled,external_url,port}` and `api.tls.{enabled,cert_path,key_path}` values are resolved with their `SUPABASE_API_*` shell/dotenv overrides applied first, so the gateway targets the same port/scheme the stack was actually @@ -87,8 +87,8 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | Variable | Purpose | Required? | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | | `SUPABASE_EXPERIMENTAL_STACK` | `1`/`0` selects the stack backend for local runs, overriding `experimental.stack` | no (→ `experimental.stack` → legacy) | -| `SUPABASE_SERVICES_HOSTNAME` | legacy backend only: override the local services host (highest precedence) | no | -| `DOCKER_HOST` | legacy backend only: when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | +| `SUPABASE_SERVICES_HOSTNAME` | legacy backend only: override the local services host (highest precedence; shell env, then project `.env*`) | no | +| `DOCKER_HOST` | legacy backend only, shell env only (a project `.env*` value is ignored): when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | | `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch; shell env only); for local, legacy-backend runs, used as the service-role key instead of `auth.service_role_key` — shell env OR project dotenv (shell wins), `encrypted:` values decrypted | no | | `SUPABASE_AUTH_JWT_SECRET` | local, legacy-backend runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation — shell env OR project dotenv (shell wins), `encrypted:` values decrypted | no | | `SUPABASE_YES` | auto-confirms the overwrite/prune prompts, same as `--yes`; read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878) | no | @@ -173,7 +173,7 @@ stdout and a terminal `result`/`error` event is emitted. `--linked`: passing it without `--linked` (i.e. targeting local) is a hard error rather than a silently discarded flag. - **Remote (`--linked`) — config override merge.** The project ref is resolved - BEFORE config is loaded. `loadCliConfig` then merges the `[remotes.]` + BEFORE config is loaded. The config load then merges the `[remotes.]` block whose `project_id` equals the resolved ref over the base config (including `storage.buckets`, `storage.vector`, `storage.analytics`). Local runs load the base config verbatim with no merge. diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 18994ae3fc..c2fb10bb13 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -1,6 +1,8 @@ import { Effect, Layer, type Option } from "effect"; import { Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -55,4 +57,6 @@ export const bucketsCommand = Command.make("buckets", config).pipe( }).pipe(withJsonErrorHandling), ), Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["seed", "buckets"]), stdinLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index 1bbdb77b06..38a34c2c64 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -14,7 +14,7 @@ import { /** * Raised when `supabase/config.toml` cannot be parsed, or a config-load-time * validation fails before any Storage call (bucket name regex, `file_size_limit` - * numeral), or by `loadCliConfig` for `env(...)` refs over numeric/bool fields. + * numeral), or by the config load for `env(...)` refs over numeric/bool fields. */ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError")<{ readonly message: string; diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 6f35f9183f..c73135ba85 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -45,7 +45,7 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( // Resolve the project ref for --linked before loading config, so the // matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadCliConfig`. `--linked` selects the + // merged over the base config by the resolved config load. `--linked` selects the // linked path whenever it's set, even `--linked=false`. const setFlags = changedLinkedLocalFlags(cliArgs.args); const isLinked = setFlags.includes("linked"); diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index b9d4dc1518..9c1c20e87b 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { loadCliConfig } from "../../../command-internal/cli-config-load.ts"; +import { loadCliConfig } from "@supabase/config/internal"; import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientError from "effect/http/HttpClientError"; @@ -42,6 +42,7 @@ import { type SetupStorageStackApiOptions, } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; interface MockRoute { readonly method: string; @@ -218,6 +219,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), mockCommandSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), BunServices.layer, + cliConfigValuesTestLayer, runtimeInfoLayer, // Seed-bucket prompts model an interactive user answering via `confirm`. mockTty({ stdinIsTty: true, stdoutIsTty: false }), @@ -294,9 +296,7 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - 'Invalid config for api.port: "not-a-port" is not a valid port', - ); + expect(Cause.pretty(exit.cause)).toContain("(sets api.port): expected a port (0-65535)."); } expect(requests).toHaveLength(0); }).pipe(seedScenario), @@ -1235,6 +1235,29 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); + it.live("prefers a shell SUPABASE_SERVICES_HOSTNAME over supabase/.env", () => + seedScenario( + withEnvVar( + "SUPABASE_SERVICES_HOSTNAME", + "shell-host", + Effect.gen(function* () { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_SERVICES_HOSTNAME=dotenv-host\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith("http://shell-host:54321"))).toBe(true); + }), + ), + ), + ); + it.live("switches the gateway to https when SUPABASE_API_TLS_ENABLED overrides the config", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { @@ -1299,9 +1322,7 @@ describe("seed buckets", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - 'Invalid config for api.port: "not-a-port" is not a valid port', - ); + expect(causeText).toContain("(sets api.port): expected a port (0-65535)."); expect(causeText).toContain("not-a-port"); } expect(requests).toHaveLength(0); @@ -1440,9 +1461,7 @@ describe("seed buckets", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - 'Invalid config for api.tls.enabled: "notabool" is not a valid boolean', - ); + expect(causeText).toContain("(sets api.tls.enabled): expected true or false."); expect(causeText).toContain("notabool"); } expect(requests).toHaveLength(0); @@ -2814,6 +2833,7 @@ describe("seed buckets", () => { ], }); const loaded = yield* loadCliConfig(tmp.current, { + cliCompat: true, search: false, }).pipe(Effect.provide(BunServices.layer)); if (loaded === null) { @@ -2864,38 +2884,23 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( - "short-circuits with the empty summary despite a malformed SUPABASE_API_PORT, since the stack backend never reads it", - () => - Effect.gen(function* () { - const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { + it.live("a malformed SUPABASE_API_PORT fails the config load under either backend", () => + Effect.gen(function* () { + for (const stackBackend of [true, false]) { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/.env": "SUPABASE_API_PORT=notaport\n" }, - stackBackend: true, - format: "json", + stackBackend, }); const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("CliConfigValueError"); + expect(causeText).toContain("(sets api.port): expected a port (0-65535)."); + } expect(requests).toHaveLength(0); - const success = out.messages.find((m) => m.type === "success"); - expect(success?.data?.["buckets_created"]).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.live("the same malformed SUPABASE_API_PORT still hard-fails under the legacy backend", () => - Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_API_PORT=notaport\n" }, - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("StorageConfigError"); - expect(causeText).toContain('Invalid config for api.port: "notaport" is not a valid port'); } - expect(requests).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/services/SIDE_EFFECTS.md b/apps/cli/src/commands/services/SIDE_EFFECTS.md index e1045ad41c..70ca8c048c 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -2,12 +2,12 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | -| `supabase/.temp/project-ref` | plain text | when the checkout is linked and no explicit ref is already loaded | -| `supabase/config.toml` and project environment files | TOML / dotenv | legacy service overrides, linked remote config, or stack PostgreSQL major selection | -| Docker client context/config metadata | JSON | when the shared local project context resolves the Docker hostname; no daemon connection is made | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and keyring access falls back to the home token file | +| Path | Format | When | +| ---------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------ | +| `supabase/.temp/project-ref` | plain text | when the checkout is linked and no explicit ref is already loaded | +| `supabase/config.json` or `config.toml`, and project environment files | JSON / TOML / dotenv | legacy service overrides, linked remote config, or stack PostgreSQL major selection | +| Docker client context/config metadata | JSON | when the shared local project context resolves the Docker hostname; no daemon connection is made | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and keyring access falls back to the home token file | ## Files Written @@ -19,9 +19,9 @@ ## API Routes **Handling of a malformed ref:** the resolved ref is validated against -`^[a-z]{20}$`. On failure it prints a warning -("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.") and -skips the remote lookup — the +`^[a-z]{20}$`. A malformed ref prints a warning +("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.") and skips the +remote lookup — the ref is embedded unescaped into the tenant gateway hostname below, so proceeding with a malformed value would let it redirect the service-role key to an attacker-controlled host. Only the local matrix is printed in this case. @@ -45,15 +45,15 @@ Tenant calls send `apikey: ` and additionally | `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable). Rewrites legacy current-pin `SERVICE IMAGE`/`name` fields to `ghcr.io/supabase/cli/`; Kong stays on docker.io. Majors 13/15 list the slim `15.14.1.167` pin when enabled; flag-off keeps `15.8.1.085`. Ignored by the stack backend. | no | -| `SUPABASE_EXPERIMENTAL_STACK` | Selects the stack backend (`1`) or legacy backend (`0`); unset or empty uses `experimental.stack`. Other values fail routing. | no | +| `SUPABASE_EXPERIMENTAL_STACK` | Selects the stack backend (`1`/`true`) or legacy backend (`0`/`false`); unset or empty uses `experimental.stack`. Non-boolean values fail routing. | no | | `SUPABASE_DB_MAJOR_VERSION` | Stack backend PostgreSQL major override; supported values are 15 and 17. | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------ | -| `0` | success; always prints the local service matrix and optionally linked versions | -| `1` | `--output env` is unsupported, or `SUPABASE_EXPERIMENTAL_STACK` has an invalid value | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------- | +| `0` | success; always prints the local service matrix and optionally linked versions | +| `1` | `--output env` is unsupported, `SUPABASE_EXPERIMENTAL_STACK` has an invalid value, or a config value is invalid | ## Output @@ -83,9 +83,10 @@ NDJSON success event with the same `{ services: [...] }` payload. ## Notes +- Config values resolve as flag > shell env > project `.env*` > config (`config.json` over `config.toml`) > default (ADR 0031). An invalid config value or `SUPABASE_*` override fails the command with exit 1, naming its source. - Backend selection follows canonical experimental-feature routing: `SUPABASE_EXPERIMENTAL_STACK=1|0` takes precedence over `experimental.stack`; unset or empty uses project config, and an invalid environment value fails. Output fields and serializers stay the same. - The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions, reported as the upstream version without a slim revision suffix (`-rN`). Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. -- For stack mode, PostgreSQL uses the configured major version or `SUPABASE_DB_MAJOR_VERSION` (15 or 17). Invalid configuration or an unsupported PostgreSQL major warns with the cause and falls back to default catalog versions; absent config uses defaults. Legacy image pins, slim-image rewriting, and remote image overrides do not affect stack results. +- For stack mode, PostgreSQL uses the configured major version or `SUPABASE_DB_MAJOR_VERSION` (15 or 17). An unreadable config file or an unsupported PostgreSQL major warns with the cause and falls back to default catalog versions; an invalid config value fails the command; absent config uses defaults. Legacy image pins, slim-image rewriting, and remote image overrides do not affect stack results. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. - A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed. - Version mismatches are reported to stderr as a warning. In stack mode, the warning explains that catalog versions cannot be changed with `supabase link`. diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index 3833a3075e..cfc870b593 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -4,11 +4,11 @@ import { resolveArtifact, } from "@supabase/stack/internal/artifacts"; import { Effect, Result } from "effect"; -import { loadLocalProjectContext } from "../../command-internal/local-project-context.ts"; +import { isConfigValueFailure } from "../../command-internal/config-value-passthrough.ts"; import { - envOverride, - envOverrideMajorVersion, -} from "../../command-internal/local-config-values.ts"; + describeConfigLoadFailure, + loadResolvedConfigContext, +} from "../../command-internal/resolved-config-context.ts"; import { stackDatabaseVersion } from "../../command-internal/stack-database-version.ts"; import { upstreamVersionFromTag } from "../../shared/services/services.shared.ts"; import type { ServiceVersionRow } from "../../shared/services/services.shared.ts"; @@ -25,32 +25,21 @@ export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(f workdir: string, remote: Partial> = {}, ) { - const context = yield* loadLocalProjectContext(workdir, (message) => message).pipe(Effect.result); + const context = yield* loadResolvedConfigContext(workdir).pipe(Effect.result); let configError: string | undefined; let databaseVersion: string | undefined; - if (Result.isFailure(context)) configError = context.failure; - else { - const resolved = yield* Effect.try({ - try: () => { - const value = envOverrideMajorVersion( - context.success.config.db.major_version, - context.success.projectEnvValues, - ); - if (value !== 15 && value !== 17) - throw new Error(`unsupported PostgreSQL major version: ${value}`); - return stackDatabaseVersion({ - major_version: value, - orioledb_version: envOverride( - "SUPABASE_DB_ORIOLEDB_VERSION", - context.success.config.db.orioledb_version, - context.success.projectEnvValues, - ), - }); - }, - catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), - }).pipe(Effect.flatMap(Effect.fromResult), Effect.result); - if (Result.isFailure(resolved)) configError = resolved.failure; - else databaseVersion = resolved.success; + if (Result.isFailure(context)) { + if (isConfigValueFailure(context.failure)) return yield* context.failure; + configError = describeConfigLoadFailure(context.failure); + } else { + const { db } = context.success.config; + if (db.major_version !== 15 && db.major_version !== 17) { + configError = `unsupported PostgreSQL major version: ${db.major_version}`; + } else { + const resolved = stackDatabaseVersion(db); + if (Result.isFailure(resolved)) configError = resolved.failure; + else databaseVersion = resolved.success; + } } yield* Effect.annotateCurrentSpan({ "config.load_failed": configError !== undefined }); return yield* Effect.forEach(artifactServiceKinds(), (service) => diff --git a/apps/cli/src/commands/services/services.command.ts b/apps/cli/src/commands/services/services.command.ts index 7a2d664905..a66e3bdd3e 100644 --- a/apps/cli/src/commands/services/services.command.ts +++ b/apps/cli/src/commands/services/services.command.ts @@ -1,4 +1,6 @@ import { Command } from "effect/cli"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import type * as CliCommand from "effect/cli/Command"; @@ -15,4 +17,6 @@ export const servicesCommand = Command.make("services", config).pipe( services(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(servicesRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index cebfeedac2..0a42aadb09 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -7,6 +7,7 @@ import { } from "../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; +import { failOnInvalidConfigValue } from "../../command-internal/config-value-passthrough.ts"; import { readDbToml } from "../../command-internal/db-config.toml-read.ts"; import { resolveDbImage } from "../../command-internal/db-image.ts"; import { resolveEdgeRuntimeImage } from "../../command-internal/edge-runtime-image.ts"; @@ -140,6 +141,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) } rows = result.rows; } else { + yield* failOnInvalidConfigValue(cliSettings.workdir, linkedProjectRef); const tomlValues = yield* readDbToml( fs, path, diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 14c520579d..09ef67fdbd 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -40,6 +40,7 @@ import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../shared/telemetry/identity.ts"; import { servicesCommand } from "./services.command.ts"; import { services } from "./services.handler.ts"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; const LOCAL_POSTGRES_VERSION = dockerfileServiceImageRaw("pg").split(":")[1] ?? ""; @@ -100,6 +101,7 @@ function setup( telemetry, cachedRefs, layer: Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, FetchHttpClient.layer, mockRuntimeInfo({ @@ -112,7 +114,6 @@ function setup( Layer.succeed( CommandSettings, CommandSettings.of({ - dbPassword: Option.none(), githubToken: Option.none(), workdirEnvValue: Option.none(), profile: "supabase", @@ -264,6 +265,7 @@ describe("services", () => { const analytics = mockAnalytics(); const args = ["services"]; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, processControlLayer, CliOutput.layer(textCliOutputFormatter()), @@ -471,11 +473,11 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("ignores config.json and reads legacy config.toml for local image selection", () => + it.live("reads config.json over config.toml for local image selection", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfigFiles({ - toml: "[db]\nmajor_version = 15\n", - json: '{"db":{"major_version":14}}', + toml: "[db]\nmajor_version = 17\n", + json: '{"db":{"major_version":15}}', }); const { layer, out } = setup({ outputFlag: Option.some("json"), workdir }); @@ -696,6 +698,38 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live.each([ + ["legacy", undefined], + ["stack", "stack"], + ] as const)( + "fails naming the source when a config value is invalid (%s backend)", + ([, backend]) => + Effect.gen(function* () { + const workdir = yield* makeProjectWithConfig('project_id = "demo"\n'); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(workdir, "supabase", ".env"), + "SUPABASE_API_PORT=notaport\n", + ); + const { layer } = setup({ workdir }); + + const exit = yield* services({}).pipe( + Effect.provide( + backend === undefined ? layer : Layer.mergeAll(layer, stackBackendLayer(backend)), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("CliConfigValueError"); + expect(causeText).toContain('Invalid SUPABASE_API_PORT="notaport"'); + } + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("prints config load errors and falls back to the default matrix", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfig("[db]\nmajor_version = "); diff --git a/apps/cli/src/commands/services/services.layers.ts b/apps/cli/src/commands/services/services.layers.ts index 4476820124..3a9b7d75f5 100644 --- a/apps/cli/src/commands/services/services.layers.ts +++ b/apps/cli/src/commands/services/services.layers.ts @@ -7,7 +7,7 @@ import { CommandCredentials } from "../../auth/command-credentials.service.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; -import { DebugLogger } from "../../command-internal/debug-logger.service.ts"; +import { DebugLogger } from "../../shared/output/debug-logger.service.ts"; import { IdentityStitch, identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { httpClientLayer } from "../../auth/http-debug.layer.ts"; import { linkedProjectCacheLayer } from "../../telemetry/linked-project-cache.layer.ts"; diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 43982748f2..c4fc2fd6b7 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -4,8 +4,9 @@ This document describes the legacy backend. With `SUPABASE_EXPERIMENTAL_STACK=1` `[experimental] stack = true` when the environment override is unset or empty, `supabase start` uses the new [`supabase stack start` implementation](../experimental/stack/start/SIDE_EFFECTS.md). `SUPABASE_EXPERIMENTAL_STACK=0` forces the legacy backend. See [backend selection](../../../docs/stack-commands.md). -Backend routing reads `supabase/config.json` when present, otherwise `supabase/config.toml`; the -legacy handler reads TOML only. Backend selection happens before command parsing. When the +Backend routing and the legacy handler both read `supabase/config.json` when present, otherwise +`supabase/config.toml`. Config values resolve as flag > shell env > project `.env*` > config > +default (ADR 0031); `start` applies no `[remotes.*]` block. Backend selection happens before command parsing. When the environment override is unset or empty, an unreadable, malformed, or invalid project configuration falls back to the legacy backend; an invalid environment override remains an error. @@ -22,7 +23,7 @@ of this command). Edge Runtime bring-up, the fresh-volume DB schema/migration/seed setup pipeline, and fresh-volume storage-bucket seeding are all natively implemented (see below). -One piece remains explicitly **out of scope**: +One piece of `start` is explicitly **out of scope**: 1. **Linked-project version-check suggestion** — a best-effort Management API call, made only when a project happens to be linked _and_ the user is logged in, purely to print @@ -98,7 +99,7 @@ command. | Path | Format | When | | ----------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/supabase/config.json` or `config.toml` | JSON or TOML | backend routing before command parsing when `SUPABASE_EXPERIMENTAL_STACK` is unset or empty; JSON takes precedence when both exist | -| `/supabase/config.toml` | TOML | always in the legacy handler | +| `/supabase/config.json` or `config.toml` | JSON or TOML | always in the legacy handler; JSON takes precedence when both exist | | `/supabase/.env`, `.env.local` | dotenv | always (`.env.local` skipped when `SUPABASE_ENV=test`) | | project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | | `auth.signing_keys_path` file | JSON | when configured | @@ -189,21 +190,20 @@ mounts and require a daemon that can see the project directory. Local-only: the Storage bucket-seeding step (fresh volume + Storage enabled) talks to the LOCAL Storage service through Kong, never the Management API. See "Scope" above for the -linked-project version-check suggestion that _would_ call the Management API and is deliberately -not implemented. +version check that _would_ call the Management API and is deliberately not implemented. ## Environment Variables | Variable | Purpose | Required? | | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_*` (any dotted config field) | Generic env override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_*` (any dotted config field) | Overrides the config key at the matching path, `SUPABASE_` plus the upper-snake path (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`). Shell beats project `.env*`, which beats config. An unparsable value fails the command; an empty value is ignored. Env for a key inside an optional section (e.g. an SMS provider) applies only when the section exists in config | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (shell → project `.env*` → config → workdir basename) | no | | `SUPABASE_WORKDIR` | Resolves `CommandSettings.workdir` | no | | `SUPABASE_YES` (or `--yes`) | Auto-confirms the fresh-volume bucket-seed overwrite/prune prompts (shell or project dotenv, same as `seed buckets`) | no | | `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read from the ambient shell only (a project `.env`/`.env.`/`.env.local` file never selects the daemon) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | | `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | | `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | | `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable) — not project dotenv. Rewrites current Dockerfile pins to `ghcr.io/supabase/cli/` (including PG15+ realtime/storage/auth migrate jobs). Kong, PG14, OrioleDB tags the catalog does not pin, historical Postgres pins, and `deno_version = 1` remain non-slim (still subject to `SUPABASE_INTERNAL_IMAGE_REGISTRY`). Majors 13/15 use the published slim PG15 pin (`15.14.1.167`) when the flag is on; flag-off keeps `15.8.1.085`. `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to slim refs | no | @@ -224,10 +224,11 @@ code is surfaced on failure. | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | +| `1` | an unparsable `SUPABASE_*` config override (e.g. a non-boolean `SUPABASE_AUTH_ENABLED`) | | `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | | `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | -| `1` | image pull exhausted across every registry candidate, or the Docker daemon becomes unreachable during the pre-pull — even with `--ignore-health-check` (see the CLI-1987 note under "Notes") | +| `1` | image pull exhausted across every registry candidate, or the Docker daemon becomes unreachable during the pre-pull — even with `--ignore-health-check` (never swallowed into exit 0; see the `--ignore-health-check` note under "Notes") | | `1` | Studio is enabled and `/supabase/snippets/` cannot be created (a file is in the way, or the workdir is not writable) — fails before any network or container is created | | `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | | `1` | health check timeout **without** `--ignore-health-check` — rolls back | @@ -290,7 +291,7 @@ buckets to prune.` for a bucket left in place. These seeding lines use the raw w ` container logs:` header and that container's `docker logs` output, then one `: ` line each. Containers are named `supabase__` throughout, rather than the id `docker create` returns. -- stderr (conditional, `exec format error` in those logs): a recovery `suggestion` printed after the reasons, naming each affected +- stderr (conditional, `exec format error` in those logs) — a recovery `suggestion` printed after the reasons, naming each affected container **with** its image (they can be named after different things — `supabase_inbucket_*` runs `mailpit`), then a `supabase stop` / ` image rm -f` / `supabase start` sequence, then a closing line for the case re-pulling cannot fix. The @@ -340,19 +341,18 @@ prose, not structured data. healthy, buckets are seeded anyway — a failure in THAT seed step still rolls back and fails the command despite the flag (see "Storage bucket seeding" and the `Exit Codes` table). -- **Image-pull/daemon failure under `--ignore-health-check` (CLI-1987):** a total image-pull - failure — every registry candidate exhausted, or the Docker daemon becoming unreachable - during the pre-pull — is never swallowed by the flag: the run exits 1 with no - `Started supabase local development setup.` line, no status table, and no security notice, - flag or no flag. This is enforced by control flow, not by a classifier: `start` consults - `isUnhealthyStartError` (`start.rollback.ts`) only inside its two health-wait failure - branches, and the image pre-pull runs before bring-up, so its failure propagates out - without ever reaching a downgrade branch. `--ignore-health-check` downgrades health-check - timeouts only. Rollback is not involved — the pre-pull runs before any container/network - is created, so there is nothing to roll back. Note the flag's own help text ("Ignore - unhealthy services and exit 0") over-promises in this scenario — a pre-pull failure is not - an "unhealthy service", but a user reading only `--help` may still expect exit 0 here. -- `--preview` is a hidden, parsed-but-inert flag, never read by the handler. +- **Image-pull/daemon failure under `--ignore-health-check`:** a total image-pull failure — + every registry candidate exhausted, or the Docker daemon becoming unreachable during the + pre-pull — is never swallowed into exit 0. This is enforced by control flow, not by a + classifier: `isUnhealthyStartError` (`start.rollback.ts`) is consulted only inside the two + health-wait failure branches, and the image pre-pull runs before bring-up, so its failure + propagates out without ever reaching a downgrade branch. The scenario exits 1 with no success + banner and no status table, flag or no flag. `--ignore-health-check` downgrades health-check + timeouts only. There is nothing to roll back, because the pre-pull runs before any + container/network is created. Note the flag's own help text ("Ignore unhealthy services and + exit 0") over-promises in this scenario — a pre-pull failure is not an "unhealthy service", + but a user reading only `--help` may still expect exit 0 here. +- `--preview` is a hidden, parsed-but-inert flag. - The already-running check uses `docker container inspect` on the Postgres container, not a health check. For a verified stopped container outside Bitbucket Pipelines, `start` removes all current-project @@ -371,10 +371,11 @@ prose, not structured data. - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database initialization, and `start` reports the existing not-running status instead. -- **Spec-strict import-map key matching (CLI-2179):** Edge Runtime bind mounts are computed by the same functions import scanner - as `functions deploy`/`functions serve` (`walkImportPaths`/`substituteImportMapValue`, - shared code), which now matches import-map keys per the import-maps spec Deno/edge-runtime - implement (exact match, or prefix match only for a `/`-suffixed key) rather than any-key - prefix matching, so maps that relied on bare-key prefix matching get fewer bind mounts; an unwalkable target - (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`, consistent with what is - documented on the `functions deploy`/`functions serve` SIDE_EFFECTS.md. +- **Spec-strict import-map key matching:** Edge Runtime bind mounts are computed by the same + functions import scanner as `functions deploy`/`functions serve` + (`walkImportPaths`/`substituteImportMapValue`, shared code), which matches import-map keys + per the import-maps spec Deno/edge-runtime implement (exact match, or prefix match only for a + `/`-suffixed key), so a bare-key prefix does not match and bind mounts are limited to + spec-matching keys; an unwalkable target (`ENOTDIR` — a value routed through a file) is + skipped with a `WARN`, as documented on the `functions deploy`/`functions serve` + SIDE_EFFECTS.md. diff --git a/apps/cli/src/commands/start/lib/env-or-default.ts b/apps/cli/src/commands/start/lib/env-or-default.ts index 8763e32582..c49eb00ea2 100644 --- a/apps/cli/src/commands/start/lib/env-or-default.ts +++ b/apps/cli/src/commands/start/lib/env-or-default.ts @@ -1,16 +1,18 @@ +import { ambientEnvironment } from "../../../shared/config/cli-config-provider.layer.ts"; + /** - * Returns the env var if set, even to an empty string — unlike - * `local-config-values.ts`'s `envOverride`, which treats an empty value as - * unset. Falls back to `def` only when the var is absent. Reads - * `process.env` directly, bypassing the `SUPABASE_`-prefixed decode-hook chain. - * `env` defaults to the live `process.env` rather than a copied snapshot: + * Returns the env var if set, even to an empty string, unlike the resolved + * config, which treats an empty value as unset. Falls back to `def` only + * when the var is absent. Reads + * the ambient environment directly, bypassing the `SUPABASE_`-prefixed decode-hook chain. + * `env` defaults to the live ambient environment rather than a copied snapshot: * Windows env lookups are case-insensitive; a snapshot record is not. */ export function envOrDefault( key: string, def: string, projectEnvValues: Readonly> | undefined, - env: Readonly> = process.env, + env: Readonly> = ambientEnvironment(), ): string { return projectEnvValues?.[key] ?? env[key] ?? def; } diff --git a/apps/cli/src/commands/start/services/studio.service.ts b/apps/cli/src/commands/start/services/studio.service.ts index 5ecb2b2ae7..affbc307b9 100644 --- a/apps/cli/src/commands/start/services/studio.service.ts +++ b/apps/cli/src/commands/start/services/studio.service.ts @@ -66,7 +66,7 @@ export interface BuildStudioEnvInput { readonly apiExtraSearchPath: ReadonlyArray; /** `config.api.max_rows` — `PGRST_DB_MAX_ROWS`. */ readonly apiMaxRows: number; - /** `envOverrideBool`-resolved `analytics.enabled` — `NEXT_PUBLIC_ENABLE_LOGS`. */ + /** The effective `analytics.enabled` — `NEXT_PUBLIC_ENABLE_LOGS`. */ readonly analyticsEnabled: boolean; /** `config.analytics.backend`, post-`SUPABASE_ANALYTICS_BACKEND`-override — `NEXT_ANALYTICS_BACKEND_PROVIDER`. */ readonly analyticsBackend: "postgres" | "bigquery"; diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index 3fc411576b..80a8905ba7 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -22,6 +22,7 @@ import { withChildTraceEnv, withProcessSpanScoped } from "../../../shared/teleme import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; import { platformDefaultDockerHost } from "../../../command-internal/hostname.ts"; import { renderStartVectorYaml } from "../lib/template-render.ts"; +import { ambientEnvironment } from "../../../shared/config/cli-config-provider.layer.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -207,7 +208,7 @@ function inspectDockerContextHost(spawner: Spawner): Effect.Effect> = process.env, + env: Readonly> = ambientEnvironment(), platform: NodeJS.Platform = process.platform, ): Effect.Effect { const fromEnv = env.DOCKER_HOST; diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index 7506be43a0..a190df5fcc 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -9,6 +9,8 @@ import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts"; import { httpClientLayer } from "../../auth/http-debug.layer.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { dbConnectionLayer } from "../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; @@ -90,4 +92,6 @@ export const startCommand = Command.make("start", config).pipe( start(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(startRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/start/start.config-precedence.integration.test.ts b/apps/cli/src/commands/start/start.config-precedence.integration.test.ts new file mode 100644 index 0000000000..bad2b0c28b --- /dev/null +++ b/apps/cli/src/commands/start/start.config-precedence.integration.test.ts @@ -0,0 +1,387 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/process"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../tests/helpers/mocks.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, + sequentialExecBatch, + useTempWorkdir, +} from "../../../tests/helpers/command-mocks.ts"; +import { ConfigEnvPins } from "../../../tests/helpers/config-env-pins.ts"; +import { goldenJson, useShellEnvPin } from "../../../tests/helpers/config-goldens.ts"; +import { unusedStackServices } from "../../../tests/helpers/unused-stack.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { + DebugFlag, + ExperimentalFlag, + NetworkIdFlag, + YesFlag, +} from "../../command-internal/global-flags.ts"; +import { DbConnection, type DbSession } from "../../command-internal/db-connection.service.ts"; +import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; +import { start } from "./start.handler.ts"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; + +const TARGET_REF = "abcdefghijklmnopqrst"; +const GOLDEN_DIR = "./testdata/config-precedence/start"; +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; + +const BASE_CONFIG = `project_id = "golden-start" + +[api] +port = 55321 +schemas = ["public", "base_api"] + +[db] +port = 55322 + +[auth] +site_url = "http://base.example.com" +enable_signup = true + +[studio] +port = 55323 +`; + +const REMOTE_BLOCK = ` +[remotes.prod] +project_id = "${TARGET_REF}" + +[remotes.prod.api] +schemas = ["public", "remote_api"] + +[remotes.prod.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.prod.realtime] +enabled = false +`; + +const SHELL_ENV = { + SUPABASE_API_PORT: "56321", + SUPABASE_DB_PORT: "56322", + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_REALTIME_ENABLED: "false", + SUPABASE_STUDIO_ENABLED: "false", +}; + +const DOTENV = [ + "SUPABASE_API_PORT=57321", + "SUPABASE_DB_PORT=57322", + "SUPABASE_API_SCHEMAS=public,dotenv_api", + "SUPABASE_AUTH_SITE_URL=http://dotenv.example.com", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_REALTIME_ENABLED=false", + "SUPABASE_STUDIO_ENABLED=false", + "", +].join("\n"); + +interface Fixture { + readonly golden: string; + readonly name: string; + readonly config: string; + readonly shellEnv?: Readonly>; + readonly dotenv?: string; +} + +const FIXTURES: ReadonlyArray = [ + { + golden: "no-overrides", + name: "start with base config and no env hands containers the base values (pre-refactor)", + config: BASE_CONFIG, + }, + { + golden: "remote-block-ignored", + name: "start ignores a [remotes.*] block even when its project_id matches a ref (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: { SUPABASE_REMOTES_PROD_PROJECT_ID: TARGET_REF }, + }, + { + golden: "shell-env", + name: "start shell env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + shellEnv: SHELL_ENV, + }, + { + golden: "project-dotenv", + name: "start supabase/.env overrides ports, api schemas, auth settings and service gates (pre-refactor)", + config: BASE_CONFIG, + dotenv: DOTENV, + }, + { + golden: "shell-beats-dotenv", + name: "start shell env beats supabase/.env for the same keys (pre-refactor)", + config: BASE_CONFIG, + shellEnv: { + SUPABASE_API_SCHEMAS: "public,shell_api", + SUPABASE_AUTH_SITE_URL: "http://shell.example.com", + }, + dotenv: DOTENV, + }, + { + golden: "remote-block-with-shell-env", + name: "start with a [remotes.*] block present and shell env resolves env over base (pre-refactor)", + config: BASE_CONFIG + REMOTE_BLOCK, + shellEnv: SHELL_ENV, + }, +]; + +interface RouteResult { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +} + +interface CreateRecord { + readonly args: ReadonlyArray; + readonly env: Readonly>; +} + +function concatByteChunks(chunks: ReadonlyArray): Uint8Array | undefined { + let byteLength = 0; + for (const chunk of chunks) { + if (!(chunk instanceof Uint8Array)) return undefined; + byteLength += chunk.byteLength; + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + if (!(chunk instanceof Uint8Array)) return undefined; + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function containerNameFromCreateArgs(args: ReadonlyArray): string { + const nameIndex = args.indexOf("--name"); + return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; +} + +function mockDockerSpawner() { + const creates: Array = []; + const copiedFiles: Record = {}; + const created = new Set(); + const encoder = new TextEncoder(); + + const route = (args: ReadonlyArray): RouteResult => { + if (args[0] === "network" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "create") { + const name = containerNameFromCreateArgs(args); + created.add(name); + return { stdout: [name] }; + } + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + return created.has(id) + ? { stdout: [HEALTHY_STATE] } + : { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + } + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + const env = command._tag === "StandardCommand" ? (command.options?.env ?? {}) : {}; + const stdin = command._tag === "StandardCommand" ? command.options.stdin : undefined; + if (args[0] === "create") creates.push({ args, env }); + + if (args[0] === "cp" && args[1] === "-" && Stream.isStream(stdin)) { + const archiveBytes = concatByteChunks(yield* Stream.runCollect(stdin)); + if (archiveBytes !== undefined) { + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + for (const [filePath, file] of files) { + copiedFiles[`${args[2] ?? ""}${filePath}`] = yield* Effect.promise(() => file.text()); + } + } + } + + const result = route(args); + const stdout = result.stdout ?? []; + const stderr = result.stderr ?? []; + const exitCode = result.exitCode ?? 0; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(5000), + stdout: Stream.fromIterable(stdout.map((line) => encoder.encode(`${line}\n`))), + stderr: Stream.fromIterable(stderr.map((line) => encoder.encode(`${line}\n`))), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + return { layer, creates, copiedFiles }; +} + +function fakeDbSession(): DbSession { + const session: DbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + execBatch: (statements) => sequentialExecBatch(session)(statements), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; +} + +function inlineEnv(args: ReadonlyArray): Readonly> { + return Object.fromEntries( + args.flatMap((arg, index) => + (args[index - 1] === "-e" || args[index - 1] === "--env") && arg.includes("=") + ? [[arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] as const] + : [], + ), + ); +} + +const STATIC_COPIED_FILES = [ + "/root/index.ts", + "/home/kong/localhost.crt", + "/home/kong/localhost.key", +]; + +function publishedPorts(args: ReadonlyArray): ReadonlyArray { + return args.flatMap((arg, index) => + args[index - 1] === "-p" || args[index - 1] === "--publish" ? [arg] : [], + ); +} + +const tempRoot = useTempWorkdir("start-golden-"); + +describe("start container env precedence goldens", () => { + const pinShellEnv = useShellEnvPin(); + + for (const fixture of FIXTURES) { + it.live(fixture.name, (ctx) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), fixture.config); + if (fixture.dotenv !== undefined) { + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), fixture.dotenv); + } + pinShellEnv(fixture.shellEnv ?? {}); + + const docker = mockDockerSpawner(); + const out = mockOutput({ format: "text" }); + const session = fakeDbSession(); + const layer = Layer.mergeAll( + unusedStackServices, + cliConfigValuesTestLayer, + Layer.succeed(ConfigEnvPins, fixture.shellEnv ?? {}), + BunServices.layer, + out.layer, + mockCommandSettings({ workdir }), + mockTelemetryStateTracked().layer, + mockAnalytics().layer, + docker.layer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), + ), + Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }), + dockerRunLayer.pipe( + Layer.provide(docker.layer), + Layer.provide(mockProcessControl().layer), + ), + mockProcessControl().layer, + mockRuntimeInfo({ platform: "linux" }), + Layer.succeed(CommandPlatformApiFactory, { + make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), + }), + Layer.succeed(CliArgs, { args: ["start"] }), + Layer.succeed(DebugFlag, false), + Layer.succeed(YesFlag, false), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + mockTty({ stdinIsTty: false }), + mockStdin(false), + Layer.succeed( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord( + Object.fromEntries( + Object.entries(process.env).flatMap(([name, value]) => + value === undefined ? [] : [[name, value]], + ), + ), + { preserveEmptyStrings: true }, + ), + ), + ); + + yield* start({ exclude: [], ignoreHealthCheck: false, preview: false }).pipe( + Effect.provide(layer), + ); + + const stagingRoot = path.join(workdir, "supabase", ".temp", "start-secrets"); + const stagedFiles = (yield* fs.readDirectory(stagingRoot, { recursive: true })).filter( + (file) => file.endsWith("docker.env"), + ); + const stagedEnvFiles: Record = {}; + for (const file of stagedFiles) { + stagedEnvFiles[file] = yield* fs.readFileString(path.join(stagingRoot, file)); + } + + const containers = Object.fromEntries( + docker.creates.map((create) => [ + containerNameFromCreateArgs(create.args), + { + env: create.env, + inlineEnv: inlineEnv(create.args), + publish: publishedPorts(create.args), + }, + ]), + ); + const golden = { + containerOrder: docker.creates.map((create) => containerNameFromCreateArgs(create.args)), + containers, + stagedEnvFiles, + copiedFiles: Object.fromEntries( + Object.entries(docker.copiedFiles).filter( + ([key]) => !STATIC_COPIED_FILES.some((file) => key.endsWith(file)), + ), + ), + }; + yield* Effect.promise(() => + ctx + .expect(goldenJson(golden, { [workdir]: "" })) + .toMatchFileSnapshot(`${GOLDEN_DIR}/${fixture.golden}.json`), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index 3c5b21aecd..fee257d8f9 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -6,7 +6,6 @@ import type { LocalServiceVersionOverrides, } from "../../shared/services/services.shared.ts"; import { resolvePinnedImage } from "../../command-internal/db-bootstrap/pinned-image.ts"; -import { envOverrideBool } from "../../command-internal/local-config-values.ts"; import { START_SERVICES } from "./start.services.ts"; /** @@ -38,16 +37,8 @@ export interface StartGates { export interface StartGateInputs { readonly config: CliConfig; - readonly projectEnvValues: Readonly> | undefined; /** `partitionStartExcludeFlags(flags.exclude).valid`, as a `Set` for O(1) lookup. */ readonly excludedKeys: ReadonlySet; - readonly document: Readonly> | undefined; -} - -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; } /** @@ -55,95 +46,26 @@ function asRecord(value: unknown): Record | undefined { * separately by the caller (see this module's header). */ export function resolveStartGates(inputs: StartGateInputs): StartGates { - const { config, projectEnvValues, excludedKeys, document } = inputs; + const { config, excludedKeys } = inputs; const isExcluded = (key: string) => excludedKeys.has(key); - const analyticsEnabled = envOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", - projectEnvValues, - ); - const apiEnabled = envOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const authEnabled = envOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const inbucketEnabled = envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const realtimeEnabled = envOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ); - const storageEnabled = envOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ); - // The section must be present in the raw document before the env override can flip it on: - // `@supabase/config` always decodes `storage.image_transformation` to a defaulted - // `{enabled: false}`, never `undefined`, so presence can't be read off the typed config. - const imageTransformationSectionPresent = - asRecord(asRecord(document?.["storage"])?.["image_transformation"]) !== undefined; - const configuredImageTransformationEnabled = - config.storage.image_transformation?.enabled ?? false; - const imageTransformationEnabled = imageTransformationSectionPresent - ? envOverrideBool( - "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", - configuredImageTransformationEnabled, - "storage.image_transformation.enabled", - projectEnvValues, - ) - : configuredImageTransformationEnabled; - const studioEnabled = envOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const poolerEnabled = envOverrideBool( - "SUPABASE_DB_POOLER_ENABLED", - config.db.pooler.enabled, - "db.pooler.enabled", - projectEnvValues, - ); - const edgeRuntimeEnabled = envOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ); - - const storage = storageEnabled && !isExcluded("storage-api"); + const storage = config.storage.enabled && !isExcluded("storage-api"); + const imageTransformationEnabled = config.storage.image_transformation?.enabled ?? false; return { kong: !isExcluded("kong"), - gotrue: authEnabled && !isExcluded("gotrue"), - mailpit: inbucketEnabled && !isExcluded("mailpit"), - realtime: realtimeEnabled && !isExcluded("realtime"), - postgrest: apiEnabled && !isExcluded("postgrest"), + gotrue: config.auth.enabled && !isExcluded("gotrue"), + mailpit: config.local_smtp.enabled && !isExcluded("mailpit"), + realtime: config.realtime.enabled && !isExcluded("realtime"), + postgrest: config.api.enabled && !isExcluded("postgrest"), storage, imgproxy: storage && imageTransformationEnabled && !isExcluded("imgproxy"), - logflare: analyticsEnabled && !isExcluded("logflare"), - vector: analyticsEnabled && !isExcluded("vector"), - pgMeta: studioEnabled && !isExcluded("postgres-meta"), - studio: studioEnabled && !isExcluded("studio"), - supavisor: poolerEnabled && !isExcluded("supavisor"), - edgeRuntime: edgeRuntimeEnabled && !isExcluded("edge-runtime"), + logflare: config.analytics.enabled && !isExcluded("logflare"), + vector: config.analytics.enabled && !isExcluded("vector"), + pgMeta: config.studio.enabled && !isExcluded("postgres-meta"), + studio: config.studio.enabled && !isExcluded("studio"), + supavisor: config.db.pooler.enabled && !isExcluded("supavisor"), + edgeRuntime: config.edge_runtime.enabled && !isExcluded("edge-runtime"), }; } diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 987ff0c7ad..3e194acb93 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -4,7 +4,6 @@ */ import { BunPath } from "@effect/platform-bun"; import { inferFunctionsManifest } from "@supabase/config/effect"; -import { resolveCliConfigSubtree } from "../../command-internal/cli-config-load.ts"; import { Effect, FileSystem, Option, Path, Result } from "effect"; import { FetchHttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; @@ -45,11 +44,7 @@ import { resolveStorageCredentials, storageGatewayFetch, } from "../../command-internal/storage-credentials.ts"; -import { - collectDotenvPrivateKeys, - decryptSecret, - isEncryptedSecret, -} from "../../command-internal/vault-decrypt.ts"; +import { decryptSecretMap } from "../../shared/config/vault-decrypt.ts"; import { parseDuration } from "../../command-internal/duration.ts"; import { configureLoopbackProxyBypass } from "../../command-internal/hostname.ts"; import { @@ -67,16 +62,6 @@ import { } from "../../command-internal/docker-lifecycle.ts"; import { dockerRemoveAll } from "../../command-internal/docker-remove-all.ts"; import { - envOverride, - envOverrideApiMaxRows, - envOverrideBool, - envOverrideDefaultPoolSize, - envOverrideDenoVersion, - envOverrideEdgeRuntimePolicy, - envOverrideMaxClientConn, - envOverridePoolMode, - envOverridePort, - envOverrideUint, resolveAuthCaptcha, resolveAuthEmail, resolveAuthEmailSmtp, @@ -99,9 +84,12 @@ import { type ResolvedAuthEmail, } from "../../command-internal/local-config-values.ts"; import { - loadLocalProjectContext, - type LocalProjectContext, -} from "../../command-internal/local-project-context.ts"; + describeConfigLoadFailure, + loadLocalResolvedConfigContext, + type LocalResolvedConfigContext, +} from "../../command-internal/resolved-config-context.ts"; +import { resolveConfigSubtree } from "../../config/cli-config-subtree.ts"; +import { CliConfigValueError } from "../../config/cli-config.errors.ts"; import { seedBucketsRun } from "../../command-internal/seed-buckets.ts"; import { cleanupStartSecrets } from "../../command-internal/start-secrets-cleanup.ts"; import { @@ -194,9 +182,16 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +const startConfigFailure = (cause: unknown) => + cause instanceof CliConfigValueError + ? new StartInvalidConfigError({ + message: `invalid config for ${cause.path}: ${cause.message}`, + }) + : new StartConfigLoadError({ message: describeConfigLoadFailure(cause) }); + /** - * Wraps a synchronous `envOverride*` config read that throws on a malformed value into a typed - * `StartInvalidConfigError`, so a bad override fails the command through the normal error path + * Wraps a synchronous config resolver that throws on a malformed value into a typed + * `StartInvalidConfigError`, so a bad value fails the command through the normal error path * instead of surfacing as an untyped Effect defect. */ function wrapConfigOverride( @@ -224,7 +219,7 @@ function wrapConfigOverride( */ function resolveGotrueEnvInput(params: { - readonly context: LocalProjectContext; + readonly context: LocalResolvedConfigContext; readonly values: LocalConfigValues; readonly workdir: string; readonly kongContainerName: string; @@ -233,20 +228,14 @@ function resolveGotrueEnvInput(params: { }): Omit { const { context, values, workdir, kongContainerName, mailpitContainerName, resolvedEmail } = params; - const { config, projectEnvValues, loaded } = context; - const document = loaded?.document; - - const inbucketEnabled = envOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); + const { config, document } = context; + + const inbucketEnabled = config.local_smtp.enabled; // Reading the schema-decoded `config.auth.email.smtp` here would always see `enabled: false` // when the key is merely absent from the TOML table, silently falling back to Mailpit even when // a real SMTP server is configured. `resolveAuthEmailSmtp` resolves this correctly off the raw // document. - const resolvedSmtp = resolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); + const resolvedSmtp = resolveAuthEmailSmtp(asRecord(document?.["auth"])); const smtp = resolvedSmtp?.enabled === true ? { @@ -258,18 +247,8 @@ function resolveGotrueEnvInput(params: { senderName: resolvedSmtp.senderName, } : undefined; - // Same override gap as `inbucketEnabled` above; these are value-typed fields, so no - // raw-document presence gate is needed. - const mailpitAdminEmail = envOverride( - "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", - config.local_smtp.admin_email, - projectEnvValues, - ); - const mailpitSenderName = envOverride( - "SUPABASE_LOCAL_SMTP_SENDER_NAME", - config.local_smtp.sender_name, - projectEnvValues, - ); + const mailpitAdminEmail = config.local_smtp.admin_email; + const mailpitSenderName = config.local_smtp.sender_name; const mailpit = smtp === undefined && inbucketEnabled ? { @@ -279,13 +258,12 @@ function resolveGotrueEnvInput(params: { } : undefined; - const { passkeyEnabled, webauthn } = resolveGotruePasskeyWebauthn(document, projectEnvValues); + const { passkeyEnabled, webauthn } = resolveGotruePasskeyWebauthn(document); const externalProviders = resolveAuthExternalProviders( asRecord(document?.["auth"]), config.auth.external, - projectEnvValues, ); - const authExternalUrl = resolveAuthExternalUrl(document, projectEnvValues); + const authExternalUrl = resolveAuthExternalUrl(document); return { apiUrl: values.apiUrl, @@ -306,22 +284,18 @@ function resolveGotrueEnvInput(params: { kongContainerName, smtp, mailpit, - sms: resolveAuthSms(asRecord(document?.["auth"]), config.auth.sms, projectEnvValues), - sessions: resolveGotrueSessions(config.auth.sessions, projectEnvValues), - mfa: resolveAuthMfa(config.auth.mfa, projectEnvValues), - rateLimit: resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - web3: resolveGotrueWeb3(config.auth.web3, projectEnvValues), - oauthServer: resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), - hooks: resolveAuthHooks(asRecord(document?.["auth"]), config.auth.hook, projectEnvValues), - captcha: resolveAuthCaptcha( - asRecord(document?.["auth"]), - config.auth.captcha, - projectEnvValues, - ), + sms: resolveAuthSms(config.auth.sms), + sessions: resolveGotrueSessions(config.auth.sessions), + mfa: resolveAuthMfa(config.auth.mfa), + rateLimit: resolveGotrueRateLimit(config.auth.rate_limit), + web3: resolveGotrueWeb3(config.auth.web3), + oauthServer: resolveGotrueOAuthServer(config.auth.oauth_server), + hooks: resolveAuthHooks(config.auth.hook), + captcha: resolveAuthCaptcha(config.auth.captcha), passkeyEnabled, webauthn, externalProviders, - signingKeys: resolveConfiguredSigningKeys(config, workdir, projectEnvValues), + signingKeys: resolveConfiguredSigningKeys(config, workdir), }; } @@ -448,9 +422,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // 2. Config load + validate — same config-load/env/project-id // resolution sequence as `stop`/`status`. - const context = yield* loadLocalProjectContext( - cliSettings.workdir, - (message) => new StartConfigLoadError({ message }), + const context = yield* loadLocalResolvedConfigContext(cliSettings.workdir).pipe( + Effect.mapError(startConfigFailure), ); const values = yield* Effect.try({ try: () => @@ -458,8 +431,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { context.config, context.hostname, cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, + context.document, ), catch: (cause) => new StartInvalidConfigError({ @@ -473,12 +445,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Single source resolved once, fed to both Kong's template mounts and GoTrue's env builder — // see {@link resolveAuthEmail}'s doc comment. const resolvedEmail = yield* Effect.try({ - try: () => - resolveAuthEmail( - config.auth.email, - asRecord(context.loaded?.document?.["auth"]), - projectEnvValues, - ), + try: () => resolveAuthEmail(config.auth.email, asRecord(context.document?.["auth"])), catch: (cause) => new StartInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), @@ -496,22 +463,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Duration fields (duration syntax) are otherwise only parsed inside GoTrue's own env // builder, which never runs when auth is disabled or `gotrue` is excluded — so a malformed // value must be validated eagerly here or it would be silently accepted. - const gotrueSessionsForValidation = resolveGotrueSessions( - config.auth.sessions, - projectEnvValues, - ); + const gotrueSessionsForValidation = resolveGotrueSessions(config.auth.sessions); yield* wrapConfigOverride("auth.email.max_frequency", () => parseDuration(resolvedEmail.max_frequency), ); // `resolveLocalConfigValues`'s own SMS validation only runs when auth is enabled, so this is // the only place a malformed `auth.sms.*` override is caught when auth is disabled. const smsForValidation = yield* Effect.try({ - try: () => - resolveAuthSms( - asRecord(context.loaded?.document?.["auth"]), - config.auth.sms, - projectEnvValues, - ), + try: () => resolveAuthSms(config.auth.sms), catch: (cause) => new StartInvalidConfigError({ message: cause instanceof Error ? cause.message : String(cause), @@ -528,12 +487,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { !smsForValidation.messagebird.enabled && !smsForValidation.textlocal.enabled && !smsForValidation.vonage.enabled && - envOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - config.auth.sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ) + config.auth.sms.enable_signup ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } @@ -548,37 +502,29 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); } yield* wrapConfigOverride("auth.mfa.phone.max_frequency", () => - parseDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), + parseDuration(resolveAuthMfa(config.auth.mfa).phone.max_frequency), ); // These GoTrue overrides must validate unconditionally too, regardless of // `auth.enabled`/`--exclude gotrue`. The resolvers already throw internally on a bad // override, so calling each once here is simpler than re-deriving every field individually. yield* wrapConfigOverride("auth.rate_limit", () => - resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - ); - yield* wrapConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), + resolveGotrueRateLimit(config.auth.rate_limit), ); + yield* wrapConfigOverride("auth.web3", () => resolveGotrueWeb3(config.auth.web3)); yield* wrapConfigOverride("auth.oauth_server", () => - resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + resolveGotrueOAuthServer(config.auth.oauth_server), ); // Same gap for the raw (unmodeled by `@supabase/config`) `auth.passkey`/`auth.webauthn`/ // `auth.external.` booleans, which are otherwise only reached once auth is enabled and // gotrue isn't excluded. - yield* wrapConfigOverride("auth.passkey", () => - resolveGotruePasskeyWebauthn(context.loaded?.document, projectEnvValues), - ); + yield* wrapConfigOverride("auth.passkey", () => resolveGotruePasskeyWebauthn(context.document)); yield* wrapConfigOverride("auth.external", () => - resolveAuthExternalProviders( - asRecord(context.loaded?.document?.["auth"]), - config.auth.external, - projectEnvValues, - ), + resolveAuthExternalProviders(asRecord(context.document?.["auth"]), config.auth.external), ); // Same gap for `auth.third_party..*` — `resolveThirdPartyProviders` is otherwise // never called by this handler at all, so a malformed override would never fail the command. yield* wrapConfigOverride("auth.third_party", () => - resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), + resolveThirdPartyProviders(config.auth.third_party), ); // `[functions..env]` has no supported meaning for `start`, so any key here must be // rejected before any Docker work. `@supabase/config`'s schema still models this table for @@ -619,7 +565,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { context.hostname, cliSettings.workdir, context.projectEnvValues, - context.loaded?.document, + context.document, precomputedLocal, ), catch: (cause) => @@ -719,34 +665,18 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // 4. No update-suggestion check: `start` has no Management API dependency by design. - // 5. Gate evaluation — see `start.gates.ts`. Wrapped because `envOverrideBool` throws - // synchronously on an unparsable value, and this handler surfaces that as a typed error. - const gates = yield* Effect.try({ - try: () => - resolveStartGates({ - config, - projectEnvValues, - excludedKeys, - document: context.loaded?.document, - }), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + // 5. Gate evaluation — see `start.gates.ts`. + const gates = resolveStartGates({ config, excludedKeys }); // 6. JWKS resolution runs unconditionally, before any image pull, regardless of which // services end up enabled. - const jwks = yield* resolveLocalJwks( - config, - cliSettings.workdir, - values.jwtSecret, - projectEnvValues, - ).pipe(Effect.mapError((cause) => new StartInvalidConfigError({ message: cause.message }))); + const jwks = yield* resolveLocalJwks(config, cliSettings.workdir, values.jwtSecret).pipe( + Effect.mapError((cause) => new StartInvalidConfigError({ message: cause.message })), + ); // The `edge_runtime.deno_version` -> image switch is start-only (no `db start` equivalent), // so it's resolved here rather than inside the shared bootstrap-config derivation below. - const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + const denoVersion = config.edge_runtime.deno_version; // Every field the fresh-DB bootstrap needs, shared with `db start`'s own native bootstrap — // see `bootstrap-config.ts`'s header for why this is one shared derivation. @@ -801,11 +731,11 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // is enabled. // // `config.functions..env.` is schema-marked deferred and only gets its literal - // interpolated by `resolveCliConfigSubtree` — without this, a configured `env` entry reaches + // interpolated by `resolveConfigSubtree` — without this, a configured `env` entry reaches // Edge Runtime as the literal string `"env(API_KEY)"` instead of the real secret. - const resolvedFunctions = yield* resolveCliConfigSubtree( + const resolvedFunctions = yield* resolveConfigSubtree( + context.resolvedConfig, config.functions, - { values: projectEnvValues ?? {} }, "functions", ); const configDeclaredFunctions = toPlainFunctionRecord(resolvedFunctions); @@ -821,7 +751,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { Effect.annotateCurrentSpan({ "function.count": Object.keys(functions).length }), ), ); - const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + const rawConfigFunctions = rawFunctionConfigRecord(context.document); // Resolve once during preflight so a missing function source cannot fail only after stopped // containers have been removed. Studio consumes the cached binds later during bring-up. const studioFunctionBinds = gates.studio @@ -856,7 +786,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const networkIdFlag = yield* NetworkIdFlag; const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: yield* supabaseEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", projectEnvValues, ), @@ -890,33 +820,11 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // The TLS cert/key disk read is gated on `api.enabled` itself, not just `api.tls.enabled`. // Resolved separately from `gates.postgrest`'s own `apiEnabled`, which is additionally - // combined with `--exclude postgrest` — a distinction config validation has no equivalent for. - const apiEnabled = yield* wrapConfigOverride("api.enabled", () => - envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), - ); - // The post-bring-up health-probe CA-trust lookup needs this same env-overridden value, not - // the raw `config.api.tls.enabled` — both the trust pool and its target URL must read one - // source of truth. - const apiTlsEnabled = yield* wrapConfigOverride("api.tls.enabled", () => - envOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ), - ); - // Same override gap as `apiTlsEnabled` above: the env overrides must apply before reading - // the cert/key paths from disk. - 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, - ); + // combined with `--exclude postgrest`. + const apiEnabled = config.api.enabled; + const apiTlsEnabled = config.api.tls.enabled; + const apiTlsCertPath = config.api.tls.cert_path; + const apiTlsKeyPath = config.api.tls.key_path; // These seed from the embedded defaults, then get replaced from disk // (below) before any Docker mutation. let tlsCertContent = KONG_LOCAL_TLS_CERT; @@ -951,182 +859,25 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); } - // Same gap for `storage.vector.enabled` — both the Storage container and `seedBucketsRun`'s - // config splice further down must see the same already-overridden value. - const storageVectorEnabled = yield* wrapConfigOverride("storage.vector.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_VECTOR_ENABLED", - config.storage.vector.enabled, - "storage.vector.enabled", - projectEnvValues, - ), - ); - // Same gap for `storage.s3_protocol.enabled`: the Storage spec builder only parsed this - // lazily, so a malformed override was silently accepted whenever Storage is excluded/disabled. - const storageS3ProtocolEnabled = yield* wrapConfigOverride("storage.s3_protocol.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ), - ); - // Same gap for `storage.analytics.enabled`. `start` never reads this field itself (only - // `seed buckets --linked` does), so it's validated here purely for fail-fast parity and the - // result discarded. - yield* wrapConfigOverride("storage.analytics.enabled", () => - envOverrideBool( - "SUPABASE_STORAGE_ANALYTICS_ENABLED", - config.storage.analytics.enabled, - "storage.analytics.enabled", - projectEnvValues, - ), - ); - // These plain `uint` fields must validate unconditionally too. `start` never reads them - // itself (only `config push`/`pull` do), so they're validated purely for fail-fast parity. - yield* wrapConfigOverride("storage.analytics.max_namespaces", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", - "storage.analytics.max_namespaces", - config.storage.analytics.max_namespaces, - projectEnvValues, - ), - ); - yield* wrapConfigOverride("storage.analytics.max_tables", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", - "storage.analytics.max_tables", - config.storage.analytics.max_tables, - projectEnvValues, - ), - ); - yield* wrapConfigOverride("storage.analytics.max_catalogs", () => - envOverrideUint( - "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", - "storage.analytics.max_catalogs", - config.storage.analytics.max_catalogs, - projectEnvValues, - ), - ); - yield* wrapConfigOverride("storage.vector.max_buckets", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", - "storage.vector.max_buckets", - config.storage.vector.max_buckets, - projectEnvValues, - ), - ); - yield* wrapConfigOverride("storage.vector.max_indexes", () => - envOverrideUint( - "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", - "storage.vector.max_indexes", - config.storage.vector.max_indexes, - projectEnvValues, - ), - ); - - // Same gap for `api.schemas`/`api.extra_search_path`/`api.max_rows` — both PostgREST's own - // container and Studio's copy of the same `PGRST_DB_*` env must see the overridden values. - const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, projectEnvValues); - const apiSchemas = - apiSchemasOverride !== undefined ? apiSchemasOverride.split(",") : config.api.schemas; - const apiExtraSearchPathOverride = envOverride( - "SUPABASE_API_EXTRA_SEARCH_PATH", - undefined, - projectEnvValues, - ); - const apiExtraSearchPath = - apiExtraSearchPathOverride !== undefined - ? apiExtraSearchPathOverride.split(",") - : config.api.extra_search_path; - const apiMaxRows = yield* wrapConfigOverride("api.max_rows", () => - envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), - ); - - // Same gap for Mailpit's three ports. `smtp_port`/`pop3_port` have no TOML default, so `?? 0` - // here preserves the "unconfigured" signal `mailpit.service.ts`'s `!== 0` publish guard checks. - const mailpitPort = yield* wrapConfigOverride("local_smtp.port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ), - ); - const mailpitSmtpPort = yield* wrapConfigOverride("local_smtp.smtp_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", - config.local_smtp.smtp_port ?? 0, - "local_smtp.smtp_port", - projectEnvValues, - ), - ); - const mailpitPop3Port = yield* wrapConfigOverride("local_smtp.pop3_port", () => - envOverridePort( - "SUPABASE_LOCAL_SMTP_POP3_PORT", - config.local_smtp.pop3_port ?? 0, - "local_smtp.pop3_port", - projectEnvValues, - ), - ); - - // Same gap for Logflare's port — `SUPABASE_ANALYTICS_PORT` must apply - // before building Logflare's host port binding. - const analyticsPort = yield* wrapConfigOverride("analytics.port", () => - envOverridePort( - "SUPABASE_ANALYTICS_PORT", - config.analytics.port, - "analytics.port", - projectEnvValues, - ), - ); - // Same gap for Logflare's deprecated `analytics.vector_port`: nothing downstream reads the - // resolved value, but a malformed override must still fail before any Docker work. - yield* wrapConfigOverride("analytics.vector_port", () => - envOverridePort( - "SUPABASE_ANALYTICS_VECTOR_PORT", - config.analytics.vector_port ?? 0, - "analytics.vector_port", - projectEnvValues, - ), - ); - - // Same gap for Supavisor's pooler fields — `pool_mode` specifically decides the published - // host port (5432 session vs 6543 transaction). Wrapped via `wrapConfigOverride` so a bad - // value fails as a typed error instead of an untyped Effect defect. - const poolerPort = yield* wrapConfigOverride("db.pooler.port", () => - envOverridePort( - "SUPABASE_DB_POOLER_PORT", - config.db.pooler.port, - "db.pooler.port", - projectEnvValues, - ), - ); - const poolMode = yield* wrapConfigOverride("db.pooler.pool_mode", () => - envOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), - ); - const poolerDefaultPoolSize = yield* wrapConfigOverride("db.pooler.default_pool_size", () => - envOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), - ); - const poolerMaxClientConn = yield* wrapConfigOverride("db.pooler.max_client_conn", () => - envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), - ); - - // `edge_runtime.policy`/`edge_runtime.inspector_port` must validate unconditionally too, - // regardless of `--exclude edge-runtime`. The Edge Runtime branch below re-resolves both - // against the env-interpolated subtree for the real container build; this eager call only - // needs the raw config value to prove it parses. - const edgeRuntimePolicy = yield* wrapConfigOverride("edge_runtime.policy", () => - envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), - ); - const edgeRuntimeInspectorPort = yield* wrapConfigOverride("edge_runtime.inspector_port", () => - envOverridePort( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - config.edge_runtime.inspector_port, - "edge_runtime.inspector_port", - projectEnvValues, - ), - ); + const storageVectorEnabled = config.storage.vector.enabled; + const storageS3ProtocolEnabled = config.storage.s3_protocol.enabled; + + const apiSchemas = config.api.schemas; + const apiExtraSearchPath = config.api.extra_search_path; + const apiMaxRows = config.api.max_rows; + + // `smtp_port`/`pop3_port` have no TOML default, so `?? 0` preserves the "unconfigured" signal + // `mailpit.service.ts`'s `!== 0` publish guard checks. + const mailpitPort = config.local_smtp.port; + const mailpitSmtpPort = config.local_smtp.smtp_port ?? 0; + const mailpitPop3Port = config.local_smtp.pop3_port ?? 0; + const analyticsPort = config.analytics.port; + const poolerPort = config.db.pooler.port; + const poolMode = config.db.pooler.pool_mode === "session" ? "session" : "transaction"; + const poolerDefaultPoolSize = config.db.pooler.default_pool_size; + const poolerMaxClientConn = config.db.pooler.max_client_conn; + const edgeRuntimePolicy = config.edge_runtime.policy; + const edgeRuntimeInspectorPort = config.edge_runtime.inspector_port; /** * Every case returns `{ spec, excludeFromHealthWatch? }`. `excludeFromHealthWatch` is @@ -1348,11 +1099,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { kongContainerName, logflareContainerName, studioApiUrl: resolveStudioApiUrl( - envOverride( - "SUPABASE_STUDIO_API_URL", - config.studio.api_url, - projectEnvValues, - ) ?? config.studio.api_url, + config.studio.api_url, context.hostname, values.apiUrl, ), @@ -1446,7 +1193,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // `postgresExtraEnv` reads this and its sibling S3 fields for its // `POSTGRES_INITDB_ARGS` branch. orioledb_version: orioledbVersion, - settings: resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + settings: resolveDbSettingsEnvOverrides(config.db.settings), }, experimental: { ...config.experimental, @@ -1502,7 +1249,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // long-running Realtime/GoTrue/PostgREST containers too), so it's reused, not re-resolved. jwks: Effect.succeed(jwks), apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl(context.loaded?.document, projectEnvValues), + authExternalUrl: resolveAuthExternalUrl(context.document), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, @@ -1551,12 +1298,12 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { if (!gates.edgeRuntime || edgeRuntimeDefaultImage === undefined) continue; // `config.edge_runtime.secrets` is still schema-decoded plain strings here — // `toPlainEdgeRuntimeConfig` only emits entries whose values are `Redacted`, which a - // value only becomes after `resolveCliConfigSubtree`'s env-interpolation and + // value only becomes after `resolveConfigSubtree`'s env-interpolation and // secret-path-redaction pass. Without this step every configured secret is silently // dropped. - const resolvedEdgeRuntime = yield* resolveCliConfigSubtree( + const resolvedEdgeRuntime = yield* resolveConfigSubtree( + context.resolvedConfig, config.edge_runtime, - { values: projectEnvValues ?? {} }, "edge_runtime", ); // Every `config.Secret`-typed field must be decrypted unconditionally so the Edge @@ -1565,28 +1312,16 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // without this step the literal ciphertext would reach the container's env file. // `checkDbToml` already validates every secret is decryptable, but discards the // decrypted plaintext there. - const rawEdgeRuntimeSecrets = toPlainEdgeRuntimeConfig(resolvedEdgeRuntime).secrets; - const dotenvPrivateKeys = collectDotenvPrivateKeys({ - ...projectEnvValues, - ...process.env, - }); - const edgeRuntimeSecrets: Record = {}; - for (const [secretName, secretValue] of Object.entries(rawEdgeRuntimeSecrets)) { - if (!isEncryptedSecret(secretValue)) { - edgeRuntimeSecrets[secretName] = secretValue; - continue; - } - const decrypted = decryptSecret(secretValue, dotenvPrivateKeys); - if (!decrypted.ok) { - return yield* new StartInvalidConfigError({ - message: `failed to parse config: ${decrypted.error}`, - }); - } - edgeRuntimeSecrets[secretName] = decrypted.value; + const decryptedEdgeRuntimeSecrets = decryptSecretMap( + toPlainEdgeRuntimeConfig(resolvedEdgeRuntime).secrets, + context.resolvedConfig.dotenvPrivateKeys, + ); + if (!decryptedEdgeRuntimeSecrets.ok) { + return yield* new StartInvalidConfigError({ + message: `failed to parse config: ${decryptedEdgeRuntimeSecrets.error}`, + }); } - // `edgeRuntimePolicy`/`edgeRuntimeInspectorPort` are resolved eagerly, before any - // Docker work — see their hoisted `wrapConfigOverride` calls next to - // `dbHealthTimeoutSeconds` above. + const edgeRuntimeSecrets = decryptedEdgeRuntimeSecrets.value; const edgeRuntimeInput: EdgeRuntimeBringUpInput = { projectId, networkId, @@ -1744,30 +1479,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // healthy. Overrides only the underlying `FetchHttpClient.Fetch` primitive, so it's a // no-op against a mock `HttpClient` (this file's own tests). // - // `effectiveLocalStorageConfig` folds the hoisted, env-overridden api/auth/storage values - // into `config` rather than the raw config, so every consumer below sees the exact - // resolved port/TLS/cert/secrets the real containers were started with. Reused for both - // this CA lookup and the two `seedBucketsRun` calls below, so bucket seeding never - // independently reloads config and drops these overrides. + // `effectiveLocalStorageConfig` folds the resolved storage values into `config`, reused by + // both `seedBucketsRun` calls below so bucket seeding never reloads config and drops them. const effectiveLocalStorageConfig = { ...config, - api: { - ...config.api, - enabled: apiEnabled, - port: values.apiPort, - external_url: values.apiUrl, - tls: { - ...config.api.tls, - enabled: apiTlsEnabled, - cert_path: apiTlsCertPath, - key_path: apiTlsKeyPath, - }, - }, - auth: { - ...config.auth, - jwt_secret: values.jwtSecret, - service_role_key: values.serviceRoleKey, - }, storage: { ...config.storage, file_size_limit: storageFileSizeLimit, @@ -1777,11 +1492,11 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }, }, }; - const { localKongCa } = yield* resolveStorageCredentials({ - projectRef: "", - config: effectiveLocalStorageConfig, - projectEnvValues, - }); + const localCredentials = yield* resolveStorageCredentials({ projectRef: "" }); + // The service-role key start derived (signing keys included) is the one the seeding client + // must present, not a re-derivation from `auth.jwt_secret`. + const storageCredentials = { ...localCredentials, apiKey: values.serviceRoleKey }; + const { localKongCa } = storageCredentials; // Shared by every gateway probe below (the bulk wait and the // storage-only recheck), so both trust the same local Kong CA. const withLocalKongCa = (effect: Effect.Effect) => @@ -1824,8 +1539,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { promptless: true, resolvedConfig: { config: effectiveLocalStorageConfig, - document: context.loaded?.document, + document: context.document, }, + credentials: storageCredentials, projectEnvValues, }).pipe(Effect.withSpan("start.seedBuckets"), Effect.result); if (Result.isFailure(seedResult)) { @@ -1860,8 +1576,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { promptless: true, resolvedConfig: { config: effectiveLocalStorageConfig, - document: context.loaded?.document, + document: context.document, }, + credentials: storageCredentials, projectEnvValues, }).pipe(Effect.withSpan("start.seedBuckets")); } diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index a8151942dd..91f8070210 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -57,6 +57,7 @@ import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { StartFlags } from "./start.command.ts"; import { start } from "./start.handler.ts"; import { KONG_LOCAL_TLS_CERT, KONG_LOCAL_TLS_KEY } from "./templates/kong-local-tls.ts"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; /** * Counts real invocations of `resolveLocalConfigValues` across this file (every test delegates @@ -506,6 +507,7 @@ const setup = Effect.fnUntraced(function* (opts: SetupOpts = {}) { const dbSession = fakeDbSession(); const layer = Layer.mergeAll( unusedStackServices, + cliConfigValuesTestLayer, BunServices.layer, out.layer, cliSettings, diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 59b3468509..30c29b14b6 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -9,11 +9,8 @@ import { runSupabaseEffect, runDockerEffect, } from "../../../tests/helpers/cli.ts"; -import { - sanitizeProjectId, - serviceContainerName, - localDbContainerId, -} from "../../command-internal/docker-ids.ts"; +import { serviceContainerName, localDbContainerId } from "../../command-internal/docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.ts"; import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index b34feac5c3..4f2350672e 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -178,12 +178,7 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => /** Real gates for `config`, with the exclusion factor neutralized (nothing excluded). */ function realGatesFor(config: CliConfig): StartGates { - return resolveStartGates({ - config, - projectEnvValues: undefined, - excludedKeys: new Set(), - document: undefined, - }); + return resolveStartGates({ config, excludedKeys: new Set() }); } function expectGatesMatchMetadata(config: CliConfig, label: string) { diff --git a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts index 878efdbea1..1158b9129b 100644 --- a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts +++ b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts @@ -23,11 +23,8 @@ import { runSupabaseEffect, runDockerEffect, } from "../../../tests/helpers/cli.ts"; -import { - sanitizeProjectId, - serviceContainerName, - localDbContainerId, -} from "../../command-internal/docker-ids.ts"; +import { serviceContainerName, localDbContainerId } from "../../command-internal/docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.ts"; class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ readonly message: string; diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/no-overrides.json b/apps/cli/src/commands/start/testdata/config-precedence/start/no-overrides.json new file mode 100644 index 0000000000..f9db4d565d --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/no-overrides.json @@ -0,0 +1,294 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_realtime_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start", + "supabase_pg_meta_golden-start", + "supabase_studio_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:55321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "false", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:55321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:55321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://base.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "55322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "55321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "55321:8000" + ] + }, + "supabase_pg_meta_golden-start": { + "env": { + "PG_META_DB_HOST": "supabase_db_golden-start", + "PG_META_DB_NAME": "postgres", + "PG_META_DB_PASSWORD": "postgres", + "PG_META_DB_PORT": "5432", + "PG_META_DB_USER": "postgres", + "PG_META_PORT": "8080" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_realtime_golden-start": { + "env": { + "API_JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "API_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "APP_NAME": "realtime", + "DB_AFTER_CONNECT_QUERY": "SET search_path TO _realtime", + "DB_ENC_KEY": "supabaserealtime", + "DB_HOST": "supabase_db_golden-start", + "DB_NAME": "postgres", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_USER": "supabase_admin", + "DNS_NODES": "''", + "ERL_AFLAGS": "-proto_dist inet_tcp", + "MAX_HEADER_LENGTH": "4096", + "METRICS_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "PORT": "4000", + "RLIMIT_NOFILE": "", + "RUN_JANITOR": "true", + "SECRET_KEY_BASE": "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + "SEED_SELF_HOST": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,base_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_studio_golden-start": { + "env": { + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "CURRENT_CLI_VERSION": "0.0.0-dev", + "EDGE_FUNCTIONS_MANAGEMENT_FOLDER": "/supabase/functions", + "HOSTNAME": "0.0.0.0", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_URL": "http://supabase_analytics_golden-start:4000", + "NEXT_ANALYTICS_BACKEND_PROVIDER": "postgres", + "NEXT_PUBLIC_ENABLE_LOGS": "true", + "OPENAI_API_KEY": "", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,base_api", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_USER_READ_WRITE": "postgres", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "SNIPPETS_MANAGEMENT_FOLDER": "/supabase/snippets", + "STUDIO_PG_META_URL": "http://supabase_pg_meta_golden-start:8080", + "SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "SUPABASE_PUBLIC_URL": "http://127.0.0.1:55321", + "SUPABASE_PUBLISHABLE_KEY": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "SUPABASE_SECRET_KEY": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "SUPABASE_SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SUPABASE_URL": "http://supabase_kong_golden-start:8000" + }, + "inlineEnv": {}, + "publish": [ + "55323:3000" + ] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=55321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/project-dotenv.json b/apps/cli/src/commands/start/testdata/config-precedence/start/project-dotenv.json new file mode 100644 index 0000000000..cf0dcec1ff --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/project-dotenv.json @@ -0,0 +1,222 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:57321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "true", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:57321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:57321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://dotenv.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "57322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "57321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "57321:8000" + ] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,dotenv_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=57321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-ignored.json b/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-ignored.json new file mode 100644 index 0000000000..f9db4d565d --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-ignored.json @@ -0,0 +1,294 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_realtime_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start", + "supabase_pg_meta_golden-start", + "supabase_studio_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:55321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "false", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:55321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:55321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:55321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://base.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "55322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "55321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "55321:8000" + ] + }, + "supabase_pg_meta_golden-start": { + "env": { + "PG_META_DB_HOST": "supabase_db_golden-start", + "PG_META_DB_NAME": "postgres", + "PG_META_DB_PASSWORD": "postgres", + "PG_META_DB_PORT": "5432", + "PG_META_DB_USER": "postgres", + "PG_META_PORT": "8080" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_realtime_golden-start": { + "env": { + "API_JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "API_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "APP_NAME": "realtime", + "DB_AFTER_CONNECT_QUERY": "SET search_path TO _realtime", + "DB_ENC_KEY": "supabaserealtime", + "DB_HOST": "supabase_db_golden-start", + "DB_NAME": "postgres", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_USER": "supabase_admin", + "DNS_NODES": "''", + "ERL_AFLAGS": "-proto_dist inet_tcp", + "MAX_HEADER_LENGTH": "4096", + "METRICS_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "PORT": "4000", + "RLIMIT_NOFILE": "", + "RUN_JANITOR": "true", + "SECRET_KEY_BASE": "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + "SEED_SELF_HOST": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,base_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_studio_golden-start": { + "env": { + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "CURRENT_CLI_VERSION": "0.0.0-dev", + "EDGE_FUNCTIONS_MANAGEMENT_FOLDER": "/supabase/functions", + "HOSTNAME": "0.0.0.0", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_URL": "http://supabase_analytics_golden-start:4000", + "NEXT_ANALYTICS_BACKEND_PROVIDER": "postgres", + "NEXT_PUBLIC_ENABLE_LOGS": "true", + "OPENAI_API_KEY": "", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,base_api", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_USER_READ_WRITE": "postgres", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "SNIPPETS_MANAGEMENT_FOLDER": "/supabase/snippets", + "STUDIO_PG_META_URL": "http://supabase_pg_meta_golden-start:8080", + "SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "SUPABASE_PUBLIC_URL": "http://127.0.0.1:55321", + "SUPABASE_PUBLISHABLE_KEY": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + "SUPABASE_SECRET_KEY": "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz", + "SUPABASE_SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SUPABASE_URL": "http://supabase_kong_golden-start:8000" + }, + "inlineEnv": {}, + "publish": [ + "55323:3000" + ] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=55321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-with-shell-env.json b/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-with-shell-env.json new file mode 100644 index 0000000000..8a46c6a9f0 --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/remote-block-with-shell-env.json @@ -0,0 +1,222 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:56321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "true", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:56321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:56321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://shell.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "56322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "56321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "56321:8000" + ] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,shell_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=56321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/shell-beats-dotenv.json b/apps/cli/src/commands/start/testdata/config-precedence/start/shell-beats-dotenv.json new file mode 100644 index 0000000000..0ffd236e72 --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/shell-beats-dotenv.json @@ -0,0 +1,222 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:57321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "true", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:57321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:57321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:57321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://shell.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "57322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "57321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "57321:8000" + ] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,shell_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=57321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/start/testdata/config-precedence/start/shell-env.json b/apps/cli/src/commands/start/testdata/config-precedence/start/shell-env.json new file mode 100644 index 0000000000..8a46c6a9f0 --- /dev/null +++ b/apps/cli/src/commands/start/testdata/config-precedence/start/shell-env.json @@ -0,0 +1,222 @@ +{ + "containerOrder": [ + "supabase_db_golden-start", + "supabase_analytics_golden-start", + "supabase_vector_golden-start", + "supabase_kong_golden-start", + "supabase_auth_golden-start", + "supabase_inbucket_golden-start", + "supabase_rest_golden-start", + "supabase_storage_golden-start", + "supabase_edge_runtime_golden-start" + ], + "containers": { + "supabase_analytics_golden-start": { + "env": { + "DB_DATABASE": "_supabase", + "DB_HOSTNAME": "supabase_db_golden-start", + "DB_PASSWORD": "postgres", + "DB_PORT": "5432", + "DB_SCHEMA": "_analytics", + "DB_USERNAME": "supabase_admin", + "LOGFLARE_FEATURE_FLAG_OVERRIDE": "'multibackend=true'", + "LOGFLARE_LOG_LEVEL": "warn", + "LOGFLARE_MIN_CLUSTER_SIZE": "1", + "LOGFLARE_NODE_HOST": "127.0.0.1", + "LOGFLARE_PRIVATE_ACCESS_TOKEN": "api-key", + "LOGFLARE_SINGLE_TENANT": "true", + "LOGFLARE_SUPABASE_MODE": "true", + "POSTGRES_BACKEND_SCHEMA": "_analytics", + "POSTGRES_BACKEND_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/_supabase", + "RELEASE_COOKIE": "cookie" + }, + "inlineEnv": {}, + "publish": [ + "54327:4000" + ] + }, + "supabase_auth_golden-start": { + "env": { + "API_EXTERNAL_URL": "http://127.0.0.1:56321/auth/v1", + "GOTRUE_API_HOST": "0.0.0.0", + "GOTRUE_API_PORT": "9999", + "GOTRUE_DB_DATABASE_URL": "postgresql://supabase_auth_admin:postgres@supabase_db_golden-start:5432/postgres", + "GOTRUE_DB_DRIVER": "postgres", + "GOTRUE_DB_MAX_IDLE_POOL_SIZE": "10", + "GOTRUE_DB_MAX_POOL_SIZE": "10", + "GOTRUE_DISABLE_SIGNUP": "true", + "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_CLIENT_ID": "", + "GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL": "false", + "GOTRUE_EXTERNAL_APPLE_ENABLED": "false", + "GOTRUE_EXTERNAL_APPLE_REDIRECT_URI": "http://127.0.0.1:56321/auth/v1/callback", + "GOTRUE_EXTERNAL_APPLE_SECRET": "", + "GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK": "false", + "GOTRUE_EXTERNAL_EMAIL_ENABLED": "true", + "GOTRUE_EXTERNAL_PHONE_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED": "false", + "GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED": "false", + "GOTRUE_JWT_ADMIN_ROLES": "service_role", + "GOTRUE_JWT_AUD": "authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME": "authenticated", + "GOTRUE_JWT_EXP": "3600", + "GOTRUE_JWT_ISSUER": "http://127.0.0.1:56321/auth/v1", + "GOTRUE_JWT_KEYS": "[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"sign\",\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"d\":\"dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU\",\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"}]", + "GOTRUE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "GOTRUE_JWT_VALIDMETHODS": "HS256,RS256,ES256", + "GOTRUE_JWT_VALID_METHODS": "HS256,RS256,ES256", + "GOTRUE_MAILER_AUTOCONFIRM": "true", + "GOTRUE_MAILER_OTP_EXP": "3600", + "GOTRUE_MAILER_OTP_LENGTH": "6", + "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED": "true", + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED": "true", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_INVITE": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY": "http://127.0.0.1:56321/auth/v1/verify", + "GOTRUE_MFA_MAX_ENROLLED_FACTORS": "10", + "GOTRUE_MFA_PHONE_ENROLL_ENABLED": "false", + "GOTRUE_MFA_PHONE_VERIFY_ENABLED": "false", + "GOTRUE_MFA_TOTP_ENROLL_ENABLED": "false", + "GOTRUE_MFA_TOTP_VERIFY_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED": "false", + "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED": "false", + "GOTRUE_PASSWORD_MIN_LENGTH": "6", + "GOTRUE_PASSWORD_REQUIRED_CHARACTERS": "", + "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS": "30", + "GOTRUE_RATE_LIMIT_EMAIL_SENT": "360000", + "GOTRUE_RATE_LIMIT_OTP": "30", + "GOTRUE_RATE_LIMIT_SMS_SENT": "30", + "GOTRUE_RATE_LIMIT_TOKEN_REFRESH": "150", + "GOTRUE_RATE_LIMIT_VERIFY": "30", + "GOTRUE_RATE_LIMIT_WEB3": "30", + "GOTRUE_SECURITY_CAPTCHA_ENABLED": "false", + "GOTRUE_SECURITY_CAPTCHA_PROVIDER": "", + "GOTRUE_SECURITY_CAPTCHA_SECRET": "", + "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED": "false", + "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL": "10", + "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED": "true", + "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": "false", + "GOTRUE_SITE_URL": "http://shell.example.com", + "GOTRUE_SMS_AUTOCONFIRM": "true", + "GOTRUE_SMS_MAX_FREQUENCY": "5s", + "GOTRUE_SMS_OTP_EXP": "6000", + "GOTRUE_SMS_OTP_LENGTH": "6", + "GOTRUE_SMS_TEMPLATE": "Your code is {{ .Code }}", + "GOTRUE_SMS_TEST_OTP": "", + "GOTRUE_SMTP_ADMIN_EMAIL": "admin@email.com", + "GOTRUE_SMTP_HOST": "supabase_inbucket_golden-start", + "GOTRUE_SMTP_MAX_FREQUENCY": "1s", + "GOTRUE_SMTP_PORT": "1025", + "GOTRUE_SMTP_SENDER_NAME": "Admin", + "GOTRUE_URI_ALLOW_LIST": "https://127.0.0.1:3000" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_db_golden-start": { + "env": { + "JWT_EXP": "3600", + "JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "POSTGRES_HOST": "/var/run/postgresql", + "POSTGRES_PASSWORD": "postgres" + }, + "inlineEnv": {}, + "publish": [ + "56322:5432" + ] + }, + "supabase_edge_runtime_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + }, + "supabase_inbucket_golden-start": { + "env": { + "MP_SMTP_DISABLE_RDNS": "true" + }, + "inlineEnv": {}, + "publish": [ + "54324:8025" + ] + }, + "supabase_kong_golden-start": { + "env": { + "KONG_DATABASE": "off", + "KONG_DECLARATIVE_CONFIG": "/home/kong/kong.yml", + "KONG_DNS_NOT_FOUND_TTL": "1", + "KONG_DNS_ORDER": "LAST,A,CNAME", + "KONG_DNS_VALID_TTL": "5", + "KONG_NGINX_PROXY_PROXY_BUFFERS": "64 160k", + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE": "160k", + "KONG_NGINX_WORKER_PROCESSES": "1", + "KONG_PLUGINS": "request-transformer,cors", + "KONG_PORT_MAPS": "56321:8000", + "KONG_SSL_CERT": "/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY": "/home/kong/localhost.key" + }, + "inlineEnv": {}, + "publish": [ + "56321:8000" + ] + }, + "supabase_rest_golden-start": { + "env": { + "PGRST_ADMIN_SERVER_PORT": "3001", + "PGRST_DB_ANON_ROLE": "anon", + "PGRST_DB_EXTRA_SEARCH_PATH": "public,extensions", + "PGRST_DB_MAX_ROWS": "1000", + "PGRST_DB_SCHEMAS": "public,shell_api", + "PGRST_DB_URI": "postgresql://authenticator:postgres@supabase_db_golden-start:5432/postgres", + "PGRST_JWT_SECRET": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_storage_golden-start": { + "env": { + "ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", + "AUTH_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long", + "DATABASE_URL": "postgresql://supabase_storage_admin:postgres@supabase_db_golden-start:5432/postgres", + "DB_MIGRATIONS_FREEZE_AT": "", + "ENABLE_IMAGE_TRANSFORMATION": "false", + "FILE_SIZE_LIMIT": "52428800", + "FILE_STORAGE_BACKEND_PATH": "/mnt", + "GLOBAL_S3_BUCKET": "stub", + "IMAGE_TRANSFORMATION_ENABLED": "false", + "IMGPROXY_URL": "http://supabase_imgproxy_golden-start:5001", + "JWT_JWKS": "{\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}", + "S3_PROTOCOL_ACCESS_KEY_ID": "625729a08b95bf1b7ff351a663f3a23c", + "S3_PROTOCOL_ACCESS_KEY_SECRET": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "S3_PROTOCOL_ENABLED": "true", + "S3_PROTOCOL_PREFIX": "/storage/v1", + "SERVICE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU", + "SIGNED_UPLOAD_URL_EXPIRATION_TIME": "7200", + "STORAGE_BACKEND": "file", + "STORAGE_S3_REGION": "local", + "TENANT_ID": "stub", + "TUS_URL_PATH": "/storage/v1/upload/resumable", + "UPLOAD_FILE_SIZE_LIMIT": "52428800000", + "UPLOAD_FILE_SIZE_LIMIT_STANDARD": "5242880000", + "VECTOR_BUCKET_PROVIDER": "pgvector", + "VECTOR_DATABASE_URL": "postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres", + "VECTOR_ENABLED": "true", + "VECTOR_STORE_MIGRATIONS_ENABLED": "true" + }, + "inlineEnv": {}, + "publish": [] + }, + "supabase_vector_golden-start": { + "env": {}, + "inlineEnv": {}, + "publish": [] + } + }, + "copiedFiles": { + "supabase_db_golden-start:/etc/postgresql-custom/pgsodium_root.key": "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + "supabase_kong_golden-start:/home/kong/kong.yml": "_format_version: \"1.1\"\nservices:\n # Tenant project endpoints\n - name: auth-v1-open\n _comment: \"GoTrue external /auth/v1/verify* -> internal root /verify*\"\n url: http://supabase_auth_golden-start:9999/verify\n routes:\n - name: auth-v1-open\n strip_path: true\n paths:\n - /auth/v1/verify\n plugins:\n - name: cors\n - name: auth-v1-open-callback\n _comment: \"GoTrue external /auth/v1/callback* -> internal root /callback*\"\n url: http://supabase_auth_golden-start:9999/callback\n routes:\n - name: auth-v1-open-callback\n strip_path: true\n paths:\n - /auth/v1/callback\n plugins:\n - name: cors\n - name: auth-v1-open-authorize\n _comment: \"GoTrue external /auth/v1/authorize* -> internal root /authorize*\"\n url: http://supabase_auth_golden-start:9999/authorize\n routes:\n - name: auth-v1-open-authorize\n strip_path: true\n paths:\n - /auth/v1/authorize\n plugins:\n - name: cors\n - name: auth-v1\n _comment: \"GoTrue external /auth/v1/* -> internal root /*\"\n url: http://supabase_auth_golden-start:9999/\n routes:\n - name: auth-v1-all\n strip_path: true\n paths:\n - /auth/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-v1\n _comment: \"PostgREST: /rest/v1/* -> http://rest:3000/*\"\n url: http://supabase_rest_golden-start:3000/\n routes:\n - name: rest-v1-all\n strip_path: true\n paths:\n - /rest/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: rest-admin-v1\n _comment: \"PostgREST: /rest-admin/v1/* -> http://rest:3001/*\"\n url: http://supabase_rest_golden-start:3001/\n routes:\n - name: rest-admin-v1-all\n strip_path: true\n paths:\n - /rest-admin/v1/\n plugins:\n - name: cors\n # TODO: validate apikey\n - name: graphql-v1\n _comment: \"PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql\"\n url: http://supabase_rest_golden-start:3000/rpc/graphql\n routes:\n - name: graphql-v1-all\n strip_path: true\n paths:\n - /graphql/v1\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Content-Profile: graphql_public\"\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: realtime-v1-ws\n _comment: \"Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket\"\n url: http://realtime-dev:4000/socket\n protocol: ws\n routes:\n - name: realtime-v1-ws\n strip_path: true\n paths:\n - /realtime/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n replace:\n querystring:\n - \"apikey:$((query_params.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (query_params.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or query_params.apikey)\"\n - name: realtime-v1-rest\n _comment: \"Realtime: /realtime/v1/* -> http://realtime:4000/api/*\"\n url: http://realtime-dev:4000/api\n protocol: http\n routes:\n - name: realtime-v1-rest\n strip_path: true\n paths:\n - /realtime/v1/api\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # S3-compatible storage endpoint (no Authorization header transformation)\n - name: storage-v1-s3\n _comment: \"Storage S3: /storage/v1/s3/* -> http://storage-api:5000/s3/*\"\n url: http://supabase_storage_golden-start:5000/s3\n routes:\n - name: storage-v1-s3-all\n strip_path: true\n paths:\n - /storage/v1/s3/\n plugins:\n - name: cors\n # Note: No request-transformer plugin for Authorization header\n # S3 presigned URLs require the original AWS signature in the Authorization header\n # Adding/replacing with Bearer token would break S3 signature validation\n # Regular storage API endpoint (with Authorization header transformation)\n - name: storage-v1\n _comment: \"Storage API: /storage/v1/* -> http://storage-api:5000/*\"\n url: http://supabase_storage_golden-start:5000/\n routes:\n - name: storage-v1-all\n strip_path: true\n paths:\n - /storage/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"Authorization: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n - name: functions-v1\n _comment: \"Functions: /functions/v1/* -> http://edge-runtime:8081/*\"\n url: http://supabase_edge_runtime_golden-start:8081/\n # Set request idle timeout to 150s to match hosted project\n # Ref: https://supabase.com/docs/guides/functions/limits\n read_timeout: 150000\n routes:\n - name: functions-v1-all\n strip_path: true\n paths:\n - /functions/v1/\n plugins:\n - name: cors\n - name: request-transformer\n config:\n add:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n replace:\n headers:\n - \"sb-api-key: $((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU') or (headers.apikey == 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH' and 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0') or headers.apikey)\"\n # Management API endpoints\n - name: well-known-oauth\n _comment: \"GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server\"\n url: http://supabase_auth_golden-start:9999/.well-known/oauth-authorization-server\n routes:\n - name: well-known-oauth\n strip_path: true\n paths:\n - /.well-known/oauth-authorization-server\n plugins:\n - name: cors\n - name: pg-meta\n _comment: \"pg-meta: /pg/* -> http://pg-meta:8080/*\"\n url: http://supabase_pg_meta_golden-start:8080/\n routes:\n - name: pg-meta-all\n strip_path: true\n paths:\n - /pg/\n plugins:\n - name: cors\n - name: analytics-v1\n _comment: \"Analytics: /analytics/v1/* -> http://logflare:4000/*\"\n url: http://supabase_analytics_golden-start:4000/\n routes:\n - name: analytics-v1-all\n strip_path: true\n paths:\n - /analytics/v1/\n plugins:\n - name: cors\n - name: pooler-v2-ws\n _comment: \"Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*\"\n url: http://supabase_pooler_golden-start:4000/v2\n protocol: ws\n routes:\n - name: pooler-v2-ws\n strip_path: true\n paths:\n - /pooler/v2/\n plugins:\n - name: cors\n - name: mcp\n _comment: \"MCP: /mcp -> http://studio:3000/api/mcp\"\n url: http://supabase_studio_golden-start:3000/api/mcp\n routes:\n - name: mcp\n strip_path: true\n paths:\n - /mcp\n plugins:\n - name: cors\n" + }, + "stagedEnvFiles": { + "supabase_edge_runtime_golden-start/env/docker.env": "SUPABASE_URL=http://kong:8000\nSUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0\nSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU\nSUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_golden-start:5432/postgres\nSUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH\nSUPABASE_INTERNAL_SECRET_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz\nSUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long\nSUPABASE_JWKS={\"keys\":[{\"kty\":\"EC\",\"kid\":\"b81269f1-21d8-4f2e-b719-c2240a840d90\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"alg\":\"ES256\",\"ext\":true,\"crv\":\"P-256\",\"x\":\"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4\",\"y\":\"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ\"},{\"kty\":\"oct\",\"k\":\"c3VwZXItc2VjcmV0LWp3dC10b2tlbi13aXRoLWF0LWxlYXN0LTMyLWNoYXJhY3RlcnMtbG9uZw\"}]}\nSUPABASE_INTERNAL_HOST_PORT=56321\nSUPABASE_INTERNAL_FUNCTIONS_CONFIG={}" + } +} diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index a0936cf4f0..059e811863 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -60,17 +60,17 @@ resolves a token at layer-build time and would break every offline/token-less `s ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | overrides the resolved local project id; ALSO the 1st candidate for the linked-state "currently linked ref" (CLI-2167 follow-up — a shared env var, two independent purposes) | 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 | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer auth for the LAZY branch-name lookup only (CLI-2167 follow-up) — never required for `status` to succeed | no (falls back to keyring → `~/.supabase/access-token`; absent → the lookup degrades, `status` still runs) | +| Variable | Purpose | Required? | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_PROJECT_ID` | overrides the resolved local project id; ALSO the 1st candidate for the linked-state "currently linked ref" (CLI-2167 follow-up — a shared env var, two independent purposes) | 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 `supabase/config.toml` or `supabase/config.json` → cwd) | +| `SUPABASE_SERVICES_HOSTNAME` | overrides the hostname used to build local service URLs (shell env, then project `.env*`) | no (falls back to the shell `DOCKER_HOST`'s tcp host → `127.0.0.1`; a project `.env*` `DOCKER_HOST` is ignored) | +| `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 | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer auth for the LAZY branch-name lookup only (CLI-2167 follow-up) — never required for `status` to succeed | no (falls back to keyring → `~/.supabase/access-token`; absent → the lookup degrades, `status` still runs) | The `SUPABASE_AUTH_*` vars follow the same `SUPABASE_`-prefixed, `.`→`_` env-var naming convention used elsewhere in config loading, and take precedence over the corresponding diff --git a/apps/cli/src/commands/status/status.command.ts b/apps/cli/src/commands/status/status.command.ts index 97648accbe..47297f575c 100644 --- a/apps/cli/src/commands/status/status.command.ts +++ b/apps/cli/src/commands/status/status.command.ts @@ -15,6 +15,8 @@ import { machineErrorContextLayer } from "../../shared/output/machine-error-cont import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { status } from "./status.handler.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; // String-slice flags: each occurrence is CSV-split and accumulated across repeats, // so `--override-name a=1,b=2` is two overrides, not one. Malformed CSV fails at parse time. @@ -91,4 +93,6 @@ export const statusCommand = Command.make("status", config).pipe( ), ), Command.provide(statusRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/status/status.handler.ts b/apps/cli/src/commands/status/status.handler.ts index f23079c505..f50577a667 100644 --- a/apps/cli/src/commands/status/status.handler.ts +++ b/apps/cli/src/commands/status/status.handler.ts @@ -28,6 +28,8 @@ import { linkedStateJsonField, resolveLinkedState, } from "../../command-internal/linked-state.ts"; +import { CliConfigValueError } from "../../config/cli-config.errors.ts"; +import { loadResolvedConfigSurfacingValueErrors } from "../../command-internal/config-value-passthrough.ts"; import { loadLocalProjectContext, recordLocalProjectOrioleDbTelemetry, @@ -138,6 +140,10 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { // 2. An absent config.toml is not a hard failure — only a malformed one is; a missing file // proceeds with template defaults. `loadLocalProjectContext` also resolves the sanitized // project id used below; see its own doc comment for the full rationale. + yield* loadResolvedConfigSurfacingValueErrors( + cliSettings.workdir, + (message) => new StatusConfigLoadError({ message }), + ); const context = yield* loadLocalProjectContext( cliSettings.workdir, (message) => new StatusConfigLoadError({ message }), @@ -154,12 +160,14 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { context.hostname, cliSettings.workdir, context.projectEnvValues, - context.loaded?.document, + context.resolvedConfig.loaded.document, ), catch: (cause) => - new StatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), + cause instanceof CliConfigValueError + ? cause + : new StatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), }); // 4. `status` has no --project-id flag: resolution is env → toml → workdir basename, then diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index f9069d733a..7c74b277c8 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -23,6 +23,7 @@ import type * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequestModule from "effect/http/HttpClientRequest"; import { vi } from "vitest"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockProcessControl } from "../../../tests/helpers/mocks.ts"; import { statusCodeFailure, @@ -365,6 +366,7 @@ function setup(opts: SetupOpts = {}) { : mockCommandPlatformApiFactoryDirect(opts.apiFactory); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, runtimeInfoLayer, out.layer, @@ -443,6 +445,7 @@ function setupFailureEnvelope(opts: FailureEnvelopeOpts) { const outputLayer = opts.format === "json" ? jsonOutputLayer : streamJsonOutputLayer; const layer = Layer.mergeAll( + cliConfigValuesTestLayer, BunServices.layer, runtimeInfoLayer, outputLayer.pipe(Layer.provide(stdio.layer)), @@ -556,6 +559,23 @@ describe("status integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails naming the source when a config value is invalid", () => { + const { layer, child } = setup(); + return Effect.gen(function* () { + yield* writeConfig(); + yield* writeSupabaseFile(tempRoot.current, ".env", "SUPABASE_API_PORT=notaport\n"); + const exit = yield* Effect.exit(status(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("CliConfigValueError"); + expect(causeText).toContain('Invalid SUPABASE_API_PORT="notaport"'); + expect(causeText).not.toContain("StatusConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { const { layer, child } = setup(); return Effect.gen(function* () { @@ -1037,6 +1057,25 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); + it.live("omits REST_URL with -o env when SUPABASE_API_ENABLED turns the api section off", () => { + const { layer, out } = setup({ + outputFlag: Option.some("env"), + route: defaultRoute({ + runningNames: ALL_RUNNING_NAMES.filter((name) => !name.includes("_rest_")), + }), + }); + return withEnvVar( + "SUPABASE_API_ENABLED", + "false", + Effect.gen(function* () { + yield* writeConfig('project_id = "demo"\n[api]\nenabled = true\n'); + yield* status(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).not.toContain("REST_URL="); + }).pipe(Effect.provide(layer)), + ); + }); + it.live("outputs a json object with -o json", () => { const { layer, out } = setup({ outputFlag: Option.some("json") }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index d0d36e0af8..13f2a47fb5 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -75,10 +75,10 @@ No Management API calls. Everything is local Docker + local `config.toml`. ## Environment Variables -| 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 `CommandSettings.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml`) | +| 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 `CommandSettings.workdir`, which locates `config.toml` on the default path | no (falls back to walking up from cwd for `supabase/config.toml` or `supabase/config.json`) | `docker`/`podman` must be resolvable on `PATH` (or reachable via the configured Docker context) — `spawnContainerCli` tries `docker` first and falls back to `podman`. When @@ -88,17 +88,17 @@ and ensure it is on PATH") rather than a generic "failed to ..." string. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------- | -| `0` | success — containers/volumes/networks pruned | -| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | -| `1` | `config.toml` present but malformed (`StopConfigLoadError`) — an **absent** file is not an error | -| `1` | listing containers failed (`StopListError`) | -| `1` | stopping one or more containers failed (`StopContainerError`) | -| `1` | `docker container prune` failed (`StopContainerPruneError`) | -| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | -| `1` | `docker network prune` failed (`StopNetworkPruneError`) | -| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — containers/volumes/networks pruned | +| `1` | `--project-id` and `--all` both set (`StopMutuallyExclusiveError`) | +| `1` | `config.toml` present but malformed (`StopConfigLoadError`), or a config value is invalid (`CliConfigValueError`, naming its source) — an **absent** file is not an error | +| `1` | listing containers failed (`StopListError`) | +| `1` | stopping one or more containers failed (`StopContainerError`) | +| `1` | `docker container prune` failed (`StopContainerPruneError`) | +| `1` | `docker volume prune` failed, only reached when volumes are being deleted (`StopVolumePruneError`) | +| `1` | `docker network prune` failed (`StopNetworkPruneError`) | +| `1` | `docker`/`podman` both absent from `PATH` (surfaces as one of the errors above) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/stop/stop.command.ts b/apps/cli/src/commands/stop/stop.command.ts index 4b1c28a03a..b596f4728d 100644 --- a/apps/cli/src/commands/stop/stop.command.ts +++ b/apps/cli/src/commands/stop/stop.command.ts @@ -9,6 +9,8 @@ import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { stop } from "./stop.handler.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; const config = { projectId: Flag.String("project-id").pipe( @@ -54,4 +56,6 @@ export const stopCommand = Command.make("stop", config).pipe( stop(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(stopRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/stop/stop.e2e.test.ts b/apps/cli/src/commands/stop/stop.e2e.test.ts index 74276679e7..1cdf6c5811 100644 --- a/apps/cli/src/commands/stop/stop.e2e.test.ts +++ b/apps/cli/src/commands/stop/stop.e2e.test.ts @@ -9,7 +9,7 @@ import { requireCliSuccess, runSupabaseEffect, } from "../../../tests/helpers/cli.ts"; -import { sanitizeProjectId } from "../../command-internal/docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.ts"; const CLI_COMMAND_TIMEOUT_MS = 60_000; const STACK_START_TIMEOUT_MS = 280_000; diff --git a/apps/cli/src/commands/stop/stop.handler.ts b/apps/cli/src/commands/stop/stop.handler.ts index 0fa66dd17c..3504fafd1f 100644 --- a/apps/cli/src/commands/stop/stop.handler.ts +++ b/apps/cli/src/commands/stop/stop.handler.ts @@ -14,6 +14,8 @@ import { } from "../../command-internal/docker-lifecycle.ts"; import { dockerRemoveAll } from "../../command-internal/docker-remove-all.ts"; import { cleanupStartSecrets } from "../../command-internal/start-secrets-cleanup.ts"; +import { CliConfigValueError } from "../../config/cli-config.errors.ts"; +import { loadResolvedConfigSurfacingValueErrors } from "../../command-internal/config-value-passthrough.ts"; import { resolveLocalConfigValues } from "../../command-internal/local-config-values.ts"; import { loadLocalProjectContext, @@ -36,7 +38,7 @@ import { * Resolves the Docker label filter `stop` searches on: `--all` bypasses config with an empty * filter; a non-empty `--project-id` (an empty string falls through like an absent flag) * overrides the resolved id directly, unsanitized; otherwise it resolves via config (env → toml - * → workdir basename, see `resolveProjectEnvironmentValues`), sanitized with `sanitizeProjectId` + * → workdir basename, see `loadCliProjectEnvFiles`), sanitized with `sanitizeProjectId` * to match the string the Docker label `start` writes. */ const resolveSearchProjectIdFilter = Effect.fn("stop.resolveSearchProjectIdFilter")(function* ( @@ -50,6 +52,10 @@ const resolveSearchProjectIdFilter = Effect.fn("stop.resolveSearchProjectIdFilte return flags.projectId.value; } + yield* loadResolvedConfigSurfacingValueErrors( + cliSettings.workdir, + (message) => new StopConfigLoadError({ message }), + ); // `loadLocalProjectContext` covers the config-load/env/project-id resolution sequence; see its // own doc comment (workdir validation is handled separately, by `stop`'s own call above). const context = yield* loadLocalProjectContext( @@ -68,13 +74,14 @@ const resolveSearchProjectIdFilter = Effect.fn("stop.resolveSearchProjectIdFilte context.config, context.hostname, cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, + context.resolvedConfig.loaded.document, ), catch: (cause) => - new StopConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), + cause instanceof CliConfigValueError + ? cause + : new StopConfigLoadError({ + message: cause instanceof Error ? cause.message : String(cause), + }), }); return context.projectId; diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index f89ba382fd..5e493be75b 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -16,6 +16,7 @@ import { import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { vi } from "vitest"; +import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { mockCommandSettings, @@ -212,6 +213,7 @@ const setup = (opts: SetupOpts = {}) => }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, runtimeInfoLayer, out.layer, cliSettings, @@ -742,6 +744,22 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("fails naming the source when a config value is invalid", () => + Effect.gen(function* () { + yield* writeSupabaseFile(tempRoot.current, ".env", "SUPABASE_API_PORT=notaport\n"); + const { layer, child } = yield* setup({ route: defaultRoute() }); + const exit = yield* Effect.exit(stop(flags()).pipe(Effect.provide(layer))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("CliConfigValueError"); + expect(causeText).toContain('Invalid SUPABASE_API_PORT="notaport"'); + expect(causeText).not.toContain("StopConfigLoadError"); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => Effect.gen(function* () { yield* writeSupabaseFile( diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index c76549e4d0..f06cc84eec 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -2,13 +2,15 @@ import { Effect } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { storageInvalidJobsMessage } from "../storage.errors.ts"; -import { parseUintBase0 } from "../../../command-internal/parse-uint.ts"; +import { parseUintBase0 } from "../../../shared/config/parse-uint.ts"; import { StorageLinkedFlagDef, StorageLocalFlagDef, @@ -108,4 +110,6 @@ export const storageCpCommand = Command.make("cp", config).pipe( }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "cp"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 69cccf48df..3a55a41c4f 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -2,6 +2,8 @@ import { Effect } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -60,4 +62,6 @@ export const storageLsCommand = Command.make("ls", config).pipe( }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "ls"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/commands/storage/ls/ls.integration.test.ts index 7d7a11bfda..6f26e4cbc5 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -244,6 +244,28 @@ describe("storage ls", () => { }); }); + it.live("applies the [remotes.*] block selected by SUPABASE_REMOTES__PROJECT_ID", () => { + const { layer, out } = setupStorage(tmp.current, { + toml: `project_id = "test" +[remotes.staging] +project_id = "env(STAGING_REF)" +`, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + return withEnvVar( + "SUPABASE_REMOTES_STAGING_PROJECT_ID", + VALID_REF, + Effect.gen(function* () { + const exit = yield* storageLs(lsFlags({ local: false })).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); + }), + ); + }); + it.live("lists the project given via --project-ref, overriding VALID_REF", () => { // The fake's own fallback stays at its default (VALID_REF); the flag must win and drive // the gateway host. diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 7af3de30d1..c957574954 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -2,6 +2,8 @@ import { Effect } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -58,4 +60,6 @@ export const storageMvCommand = Command.make("mv", config).pipe( }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "mv"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index 35c3babe0c..c8605a3175 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -1,6 +1,8 @@ import { Effect, Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -68,4 +70,6 @@ export const storageRmCommand = Command.make("rm", config).pipe( }).pipe(withJsonErrorHandling), ), Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["storage", "rm"]), stdinLayer)), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), ); diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 8677f747e2..5013dc45b6 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -7,7 +7,7 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { resolveYesWithProjectEnv } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { bold } from "../../../command-internal/colors.ts"; -import { loadProjectEnv } from "../../../command-internal/db-config.toml-read.ts"; +import { loadProjectEnvValues } from "../../../command-internal/db-config.toml-read.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; import { DELETE_OBJECTS_LIMIT, @@ -78,7 +78,7 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag linkedRef = projectRef; // `.env` loads before the confirmation prompt, so a `SUPABASE_YES` set only in // `supabase/.env` also auto-confirms. - const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); + const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const loaded = yield* loadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index baf023110f..45de7134e3 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -1,11 +1,11 @@ -import { CliConfigSchema, type CliConfig } from "@supabase/config/effect"; -import { - loadCliConfig, - type CliConfigLoadOptions, -} from "../../command-internal/cli-config-load.ts"; -import { Effect, FileSystem, Schema } from "effect"; +import { findCliProjectPaths, type CliConfig } from "@supabase/config/effect"; +import { Effect, FileSystem, Option } from "effect"; import { FetchHttpClient } from "effect/http"; +import { + describeConfigLoadFailure, + loadResolvedConfigContext, +} from "../../command-internal/resolved-config-context.ts"; import { resolveStorageCredentials, storageGatewayFetch, @@ -19,7 +19,6 @@ import { } from "../../command-internal/storage-url.ts"; import { StorageConfigError } from "../../command-internal/storage-credentials.errors.ts"; import { missingProjectConfigMessageEffect } from "../../command-internal/workdir-project.ts"; -import { shouldSearchAncestors } from "../../command-internal/workdir-search.ts"; import { validateWorkdirIsDirectory } from "../../command-internal/workdir-validation.ts"; import { StorageInvalidUrlError, @@ -33,8 +32,6 @@ import { * (`--local`'s value decides local vs linked) and building the gateway client. */ -const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); - interface LoadedStorageConfig { readonly config: CliConfig; readonly document: Record | undefined; @@ -42,22 +39,29 @@ interface LoadedStorageConfig { } /** - * Loads `supabase/config.toml`, falling back to the embedded defaults when it's - * missing — except for a local target with an explicit `--workdir`, which raises - * `StorageMissingProjectConfigError` instead (see that error's doc for why). A - * remote target never hard-fails this way, since credential resolution doesn't - * read `config` at all. `appliedRemote` is set when a `[remotes.]` block - * matches the linked ref. + * Loads the config through `CliConfigValues` (flags, env and the `[remotes.]` block matching + * `projectRef` applied), falling back to the embedded defaults when no project file exists — + * except for a local target with an explicit `--workdir`, which raises + * `StorageMissingProjectConfigError` instead (see that error's doc for why). A remote target + * never hard-fails this way, since credential resolution doesn't read `config` at all. + * `appliedRemote` is set when a `[remotes.]` block matches the linked ref. */ export const loadStorageConfig = Effect.fn("Storage.loadConfig")(function* ( cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, projectRef: string, ) { - const loadOptions: CliConfigLoadOptions = - projectRef !== "" - ? { projectRef, search: shouldSearchAncestors(cliSettings) } - : { search: shouldSearchAncestors(cliSettings) }; - const loaded = yield* loadCliConfig(cliSettings.workdir, loadOptions).pipe( + if (cliSettings.explicitWorkdir && projectRef === "") { + const paths = yield* findCliProjectPaths(cliSettings.workdir, { search: false }); + if (paths === null) { + return yield* new StorageMissingProjectConfigError({ + message: yield* missingProjectConfigMessageEffect(cliSettings), + }); + } + } + const context = yield* loadResolvedConfigContext( + cliSettings.workdir, + projectRef === "" ? Option.none() : Option.some(projectRef), + ).pipe( Effect.catchTag( "CliConfigParseError", (cause) => @@ -65,23 +69,16 @@ export const loadStorageConfig = Effect.fn("Storage.loadConfig")(function* ( message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, }), ), + Effect.mapError((cause) => + cause instanceof StorageConfigError + ? cause + : new StorageConfigError({ message: describeConfigLoadFailure(cause) }), + ), ); - if (loaded === null) { - if (cliSettings.explicitWorkdir && projectRef === "") { - return yield* new StorageMissingProjectConfigError({ - message: yield* missingProjectConfigMessageEffect(cliSettings), - }); - } - return { - config: decodeDefaultCliConfig({}), - document: undefined, - appliedRemote: undefined, - } satisfies LoadedStorageConfig; - } return { - config: loaded.config, - document: loaded.document, - appliedRemote: loaded.appliedRemote, + config: context.config, + document: context.document, + appliedRemote: Option.getOrUndefined(context.resolvedConfig.appliedRemote), } satisfies LoadedStorageConfig; }); @@ -105,10 +102,7 @@ export const connectStorageGateway = ( body: (gateway: StorageGateway) => Effect.Effect, ) => Effect.gen(function* () { - const credentials = yield* resolveStorageCredentials({ - projectRef: opts.projectRef, - config: opts.config, - }); + const credentials = yield* resolveStorageCredentials({ projectRef: opts.projectRef }); const gatewayOps = Effect.gen(function* () { const gateway = yield* makeStorageGateway({ baseUrl: credentials.baseUrl, diff --git a/apps/cli/src/commands/test/db/db.command.ts b/apps/cli/src/commands/test/db/db.command.ts index 8f033c8c97..ccf1ad2949 100644 --- a/apps/cli/src/commands/test/db/db.command.ts +++ b/apps/cli/src/commands/test/db/db.command.ts @@ -7,6 +7,8 @@ import { testDbConfig, } from "../../../command-internal/test-db.command-handler.ts"; import { testDbRuntimeLayer } from "../../../command-internal/test-db.layers.ts"; +import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; /** * `test db` — the visible entry point. Its hidden alias `db test` reuses the same @@ -17,4 +19,6 @@ export const testDbCommand = Command.make("db", testDbConfig).pipe( Command.withShortDescription(TEST_DB_SHORT), Command.withHandler(runTestDbCommand), Command.provide(testDbRuntimeLayer(["test", "db"])), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(testDbConfig), ); diff --git a/apps/cli/src/commands/unlink/unlink.handler.ts b/apps/cli/src/commands/unlink/unlink.handler.ts index 7049256544..a4f83afd7e 100644 --- a/apps/cli/src/commands/unlink/unlink.handler.ts +++ b/apps/cli/src/commands/unlink/unlink.handler.ts @@ -7,7 +7,7 @@ import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { PROJECT_NOT_LINKED_MESSAGE } from "../../config/project-ref.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { tempPaths } from "../../command-internal/temp-paths.ts"; +import { tempPaths } from "../../shared/config/temp-paths.ts"; import { UnlinkRefReadError, UnlinkTempRemovalError } from "./unlink.errors.ts"; export const unlink = Effect.fn("unlink")(function* () { diff --git a/apps/cli/src/commands/whoami/SIDE_EFFECTS.md b/apps/cli/src/commands/whoami/SIDE_EFFECTS.md index 2b8f44e92a..a29203f485 100644 --- a/apps/cli/src/commands/whoami/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/whoami/SIDE_EFFECTS.md @@ -10,7 +10,7 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `/access-token` | plain text (token string) | last-resort fallback after environment and keyring lookup | | `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | -| `/supabase/config.toml` | existence probe | while resolving the default workdir; skipped when `--workdir` or `SUPABASE_WORKDIR` is set | +| `/supabase/config.toml` or `config.json` | existence probe | while resolving the default workdir; skipped when `--workdir` or `SUPABASE_WORKDIR` is set | | `/proc/sys/kernel/osrelease` | existence probe and text | during credential initialization to detect WSL; read only when the path exists | ## Files Written diff --git a/apps/cli/src/config/cli-config-codec-schemas.ts b/apps/cli/src/config/cli-config-codec-schemas.ts new file mode 100644 index 0000000000..259f0e7c8e --- /dev/null +++ b/apps/cli/src/config/cli-config-codec-schemas.ts @@ -0,0 +1,173 @@ +import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect"; +import { TomlDate } from "smol-toml"; + +import { parseBase0Bigint } from "../shared/config/parse-uint.ts"; +import { parseBoolLiteral } from "../shared/config/config-bool.ts"; + +const MAX_UINT = BigInt(Number.MAX_SAFE_INTEGER); +const MAX_PORT = 65535; + +/** A schema pair per value type: `fromString` decodes env and flag text, `fromDocument` a typed config value. */ +export interface CliConfigCodecSchemas { + readonly fromString: Schema.Codec; + readonly fromDocument: Schema.Codec; +} + +const parsed = (parse: (input: E) => T | undefined) => + SchemaGetter.transformEffect((input) => { + const value = parse(input); + return value === undefined + ? Effect.fail(new SchemaIssue.InvalidValue(undefined, input)) + : Effect.succeed(value); + }); + +const parseUintUpTo = (max: bigint) => (raw: string) => { + const value = parseBase0Bigint(raw); + return value === undefined || value > max ? undefined : Number(value); +}; + +const integerUpTo = (max: number) => (value: number) => + Number.isInteger(value) && value >= 0 && value <= max ? value : undefined; + +const parsePort = (raw: string) => + /^0[0-9_]/.test(raw) ? undefined : parseUintUpTo(BigInt(MAX_PORT))(raw); + +const integerFromString = (parse: (raw: string) => number | undefined) => + Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: parsed(parse), + encode: SchemaGetter.transform((value: number) => String(value)), + }), + ); + +const integerFromNumber = (max: number) => + Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: parsed(integerUpTo(max)), + encode: SchemaGetter.passthrough(), + }), + ); + +const integerSchemas = ( + parse: (raw: string) => number | undefined, + max: number, +): CliConfigCodecSchemas => { + const fromString = integerFromString(parse); + return { + fromString, + fromDocument: Schema.Union([fromString, integerFromNumber(max)]), + }; +}; + +const boolFromString = Schema.String.pipe( + Schema.decodeTo(Schema.Boolean, { + decode: parsed(parseBoolLiteral), + encode: SchemaGetter.transform((value: boolean) => String(value)), + }), +); + +const boolFromNumber = Schema.Number.pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value: number) => value !== 0), + encode: SchemaGetter.transform((value: boolean) => (value ? 1 : 0)), + }), +); + +export const boolSchemas: CliConfigCodecSchemas = { + fromString: boolFromString, + fromDocument: Schema.Union([Schema.Boolean, boolFromNumber, boolFromString]), +}; + +export const uintSchemas = integerSchemas(parseUintUpTo(MAX_UINT), Number.MAX_SAFE_INTEGER); + +export const portSchemas = integerSchemas(parsePort, MAX_PORT); + +export const stringSchemas: CliConfigCodecSchemas = { + fromString: Schema.String, + fromDocument: Schema.String, +}; + +/** No trimming; the empty string is the empty list. */ +const listFromString = Schema.String.pipe( + Schema.decodeTo(Schema.Array(Schema.String), { + decode: SchemaGetter.split(), + encode: SchemaGetter.transform((values: ReadonlyArray) => values.join(",")), + }), +); + +export const commaListSchemas: CliConfigCodecSchemas> = { + fromString: listFromString, + fromDocument: Schema.Union([listFromString, Schema.Array(Schema.String)]), +}; + +/** A float rendered in fixed notation, with `+Inf`/`-Inf`/`NaN` and a signed zero spelled out. */ +const formatWeakFloat = (value: number): string => { + if (Number.isNaN(value)) return "NaN"; + if (value === Number.POSITIVE_INFINITY) return "+Inf"; + if (value === Number.NEGATIVE_INFINITY) return "-Inf"; + if (Object.is(value, -0)) return "-0"; + const text = value.toString(); + const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text); + if (match === null) return text; + const [, sign = "", intPart = "", fracPart = "", exponent = "0"] = match; + const digits = intPart + fracPart; + const pointAt = intPart.length + Number(exponent); + if (pointAt <= 0) return `${sign}0.${"0".repeat(-pointAt)}${digits}`; + if (pointAt >= digits.length) return `${sign}${digits}${"0".repeat(pointAt - digits.length)}`; + return `${sign}${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`; +}; + +/** A bool becomes `"1"`/`"0"` and a number its decimal text; anything else is not a scalar. */ +const weakGlobEntry = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "1" : "0"; + if (typeof value === "number") return formatWeakFloat(value); + if (typeof value === "bigint") return value.toString(); + return undefined; +}; + +export const unsupportedTypeName = (value: unknown): string | undefined => { + if (value instanceof TomlDate) { + if (value.isDate()) return "local date"; + if (value.isTime()) return "local time"; + return value.isLocal() ? "local date-time" : "offset date-time"; + } + if (Array.isArray(value)) return "array"; + return typeof value === "object" && value !== null ? "table" : undefined; +}; + +const isEmptyTable = (value: unknown): boolean => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof TomlDate) && + Object.keys(value).length === 0; + +export const globEntries = (value: unknown): ReadonlyArray | undefined => { + if (typeof value === "string") return undefined; + if (Array.isArray(value)) return value; + return isEmptyTable(value) ? [] : [value]; +}; + +const globFromScalarOrArray = Schema.Unknown.pipe( + Schema.decodeTo(Schema.Array(Schema.String), { + decode: parsed((value: unknown) => { + const decoded = (globEntries(value) ?? []).map(weakGlobEntry); + return decoded.every((entry) => entry !== undefined) ? decoded : undefined; + }), + encode: SchemaGetter.passthrough(), + }), +); + +/** Weak decoding: a bare string is comma-split; numbers and bools in a scalar or array become their text. */ +export const globListSchemas: CliConfigCodecSchemas> = { + fromString: listFromString, + fromDocument: Schema.Union([listFromString, globFromScalarOrArray]), +}; + +export const literalSchemas = ( + values: ReadonlyArray, +): CliConfigCodecSchemas => { + const literal = Schema.Literals(values); + return { fromString: literal, fromDocument: literal }; +}; diff --git a/apps/cli/src/config/cli-config-codec-schemas.unit.test.ts b/apps/cli/src/config/cli-config-codec-schemas.unit.test.ts new file mode 100644 index 0000000000..c05a288441 --- /dev/null +++ b/apps/cli/src/config/cli-config-codec-schemas.unit.test.ts @@ -0,0 +1,238 @@ +import { inspect } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { + boolCodec, + commaListCodec, + globListCodec, + literalCodec, + portCodec, + stringCodec, + uintCodec, +} from "./cli-config-key.ts"; + +const codecs = [ + boolCodec, + uintCodec, + portCodec, + commaListCodec, + globListCodec, + literalCodec(["a", "b"]), + stringCodec, +]; + +const strings = [ + "0x10", + "0X1F", + "0b101", + "0o17", + "010", + "08", + "00", + "0", + "0_7", + "0x_1f", + "1_000", + "1__0", + "_1", + "1_", + "+1", + "-0", + "-1", + "", + "1", + "65535", + "65536", + "065535", + "0_1", + "070000", + "9007199254740991", + "9007199254740992", + "9007199254740993", + "18446744073709551616", + "TRUE", + "tRue", + "True", + "t", + "F", + "false", + "0b", + "0x", + "abc", + "a", + "b", + "a,b", + "a, b", + ",", + "a,,b", + " 1", +]; + +const documentValues: ReadonlyArray = [ + 0, + 1, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + -0, + 65535, + 65536, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER + 2, + 5n, + true, + false, + null, + undefined, + {}, + { a: 1 }, + [], + ["a", "b"], + ["a", 1], + [1, true], + [{}], + [[]], + [["a"]], + [null], + 1e21, + 1e-7, + 0.1, + -1.5e-7, +]; + +const row = (input: unknown, decode: (codec: (typeof codecs)[number]) => unknown) => + [input, ...codecs.map(decode)].map((cell) => inspect(cell, { depth: 4 })).join(" | "); + +describe("config codecs over an edge set", () => { + it("parses env and flag text as before", () => { + const table = strings.map((raw) => row(raw, (codec) => codec.parse(raw))).join("\n"); + + expect(table).toBe(`'0x10' | undefined | 16 | 16 | [ '0x10' ] | [ '0x10' ] | undefined | '0x10' +'0X1F' | undefined | 31 | 31 | [ '0X1F' ] | [ '0X1F' ] | undefined | '0X1F' +'0b101' | undefined | 5 | 5 | [ '0b101' ] | [ '0b101' ] | undefined | '0b101' +'0o17' | undefined | 15 | 15 | [ '0o17' ] | [ '0o17' ] | undefined | '0o17' +'010' | undefined | 8 | undefined | [ '010' ] | [ '010' ] | undefined | '010' +'08' | undefined | undefined | undefined | [ '08' ] | [ '08' ] | undefined | '08' +'00' | undefined | 0 | undefined | [ '00' ] | [ '00' ] | undefined | '00' +'0' | false | 0 | 0 | [ '0' ] | [ '0' ] | undefined | '0' +'0_7' | undefined | 7 | undefined | [ '0_7' ] | [ '0_7' ] | undefined | '0_7' +'0x_1f' | undefined | 31 | 31 | [ '0x_1f' ] | [ '0x_1f' ] | undefined | '0x_1f' +'1_000' | undefined | 1000 | 1000 | [ '1_000' ] | [ '1_000' ] | undefined | '1_000' +'1__0' | undefined | undefined | undefined | [ '1__0' ] | [ '1__0' ] | undefined | '1__0' +'_1' | undefined | undefined | undefined | [ '_1' ] | [ '_1' ] | undefined | '_1' +'1_' | undefined | undefined | undefined | [ '1_' ] | [ '1_' ] | undefined | '1_' +'+1' | undefined | undefined | undefined | [ '+1' ] | [ '+1' ] | undefined | '+1' +'-0' | undefined | undefined | undefined | [ '-0' ] | [ '-0' ] | undefined | '-0' +'-1' | undefined | undefined | undefined | [ '-1' ] | [ '-1' ] | undefined | '-1' +'' | false | undefined | undefined | [] | [] | undefined | '' +'1' | true | 1 | 1 | [ '1' ] | [ '1' ] | undefined | '1' +'65535' | undefined | 65535 | 65535 | [ '65535' ] | [ '65535' ] | undefined | '65535' +'65536' | undefined | 65536 | undefined | [ '65536' ] | [ '65536' ] | undefined | '65536' +'065535' | undefined | 27485 | undefined | [ '065535' ] | [ '065535' ] | undefined | '065535' +'0_1' | undefined | 1 | undefined | [ '0_1' ] | [ '0_1' ] | undefined | '0_1' +'070000' | undefined | 28672 | undefined | [ '070000' ] | [ '070000' ] | undefined | '070000' +'9007199254740991' | undefined | 9007199254740991 | undefined | [ '9007199254740991' ] | [ '9007199254740991' ] | undefined | '9007199254740991' +'9007199254740992' | undefined | undefined | undefined | [ '9007199254740992' ] | [ '9007199254740992' ] | undefined | '9007199254740992' +'9007199254740993' | undefined | undefined | undefined | [ '9007199254740993' ] | [ '9007199254740993' ] | undefined | '9007199254740993' +'18446744073709551616' | undefined | undefined | undefined | [ '18446744073709551616' ] | [ '18446744073709551616' ] | undefined | '18446744073709551616' +'TRUE' | true | undefined | undefined | [ 'TRUE' ] | [ 'TRUE' ] | undefined | 'TRUE' +'tRue' | undefined | undefined | undefined | [ 'tRue' ] | [ 'tRue' ] | undefined | 'tRue' +'True' | true | undefined | undefined | [ 'True' ] | [ 'True' ] | undefined | 'True' +'t' | true | undefined | undefined | [ 't' ] | [ 't' ] | undefined | 't' +'F' | false | undefined | undefined | [ 'F' ] | [ 'F' ] | undefined | 'F' +'false' | false | undefined | undefined | [ 'false' ] | [ 'false' ] | undefined | 'false' +'0b' | undefined | undefined | undefined | [ '0b' ] | [ '0b' ] | undefined | '0b' +'0x' | undefined | undefined | undefined | [ '0x' ] | [ '0x' ] | undefined | '0x' +'abc' | undefined | undefined | undefined | [ 'abc' ] | [ 'abc' ] | undefined | 'abc' +'a' | undefined | undefined | undefined | [ 'a' ] | [ 'a' ] | 'a' | 'a' +'b' | undefined | undefined | undefined | [ 'b' ] | [ 'b' ] | 'b' | 'b' +'a,b' | undefined | undefined | undefined | [ 'a', 'b' ] | [ 'a', 'b' ] | undefined | 'a,b' +'a, b' | undefined | undefined | undefined | [ 'a', ' b' ] | [ 'a', ' b' ] | undefined | 'a, b' +',' | undefined | undefined | undefined | [ '', '' ] | [ '', '' ] | undefined | ',' +'a,,b' | undefined | undefined | undefined | [ 'a', '', 'b' ] | [ 'a', '', 'b' ] | undefined | 'a,,b' +' 1' | undefined | undefined | undefined | [ ' 1' ] | [ ' 1' ] | undefined | ' 1'`); + }); + + it("decodes document values as before", () => { + const table = [...strings, ...documentValues] + .map((value) => row(value, (codec) => codec.fromConfig(value))) + .join("\n"); + + expect(table).toBe(`'0x10' | undefined | 16 | 16 | [ '0x10' ] | [ '0x10' ] | undefined | '0x10' +'0X1F' | undefined | 31 | 31 | [ '0X1F' ] | [ '0X1F' ] | undefined | '0X1F' +'0b101' | undefined | 5 | 5 | [ '0b101' ] | [ '0b101' ] | undefined | '0b101' +'0o17' | undefined | 15 | 15 | [ '0o17' ] | [ '0o17' ] | undefined | '0o17' +'010' | undefined | 8 | undefined | [ '010' ] | [ '010' ] | undefined | '010' +'08' | undefined | undefined | undefined | [ '08' ] | [ '08' ] | undefined | '08' +'00' | undefined | 0 | undefined | [ '00' ] | [ '00' ] | undefined | '00' +'0' | false | 0 | 0 | [ '0' ] | [ '0' ] | undefined | '0' +'0_7' | undefined | 7 | undefined | [ '0_7' ] | [ '0_7' ] | undefined | '0_7' +'0x_1f' | undefined | 31 | 31 | [ '0x_1f' ] | [ '0x_1f' ] | undefined | '0x_1f' +'1_000' | undefined | 1000 | 1000 | [ '1_000' ] | [ '1_000' ] | undefined | '1_000' +'1__0' | undefined | undefined | undefined | [ '1__0' ] | [ '1__0' ] | undefined | '1__0' +'_1' | undefined | undefined | undefined | [ '_1' ] | [ '_1' ] | undefined | '_1' +'1_' | undefined | undefined | undefined | [ '1_' ] | [ '1_' ] | undefined | '1_' +'+1' | undefined | undefined | undefined | [ '+1' ] | [ '+1' ] | undefined | '+1' +'-0' | undefined | undefined | undefined | [ '-0' ] | [ '-0' ] | undefined | '-0' +'-1' | undefined | undefined | undefined | [ '-1' ] | [ '-1' ] | undefined | '-1' +'' | false | undefined | undefined | [] | [] | undefined | '' +'1' | true | 1 | 1 | [ '1' ] | [ '1' ] | undefined | '1' +'65535' | undefined | 65535 | 65535 | [ '65535' ] | [ '65535' ] | undefined | '65535' +'65536' | undefined | 65536 | undefined | [ '65536' ] | [ '65536' ] | undefined | '65536' +'065535' | undefined | 27485 | undefined | [ '065535' ] | [ '065535' ] | undefined | '065535' +'0_1' | undefined | 1 | undefined | [ '0_1' ] | [ '0_1' ] | undefined | '0_1' +'070000' | undefined | 28672 | undefined | [ '070000' ] | [ '070000' ] | undefined | '070000' +'9007199254740991' | undefined | 9007199254740991 | undefined | [ '9007199254740991' ] | [ '9007199254740991' ] | undefined | '9007199254740991' +'9007199254740992' | undefined | undefined | undefined | [ '9007199254740992' ] | [ '9007199254740992' ] | undefined | '9007199254740992' +'9007199254740993' | undefined | undefined | undefined | [ '9007199254740993' ] | [ '9007199254740993' ] | undefined | '9007199254740993' +'18446744073709551616' | undefined | undefined | undefined | [ '18446744073709551616' ] | [ '18446744073709551616' ] | undefined | '18446744073709551616' +'TRUE' | true | undefined | undefined | [ 'TRUE' ] | [ 'TRUE' ] | undefined | 'TRUE' +'tRue' | undefined | undefined | undefined | [ 'tRue' ] | [ 'tRue' ] | undefined | 'tRue' +'True' | true | undefined | undefined | [ 'True' ] | [ 'True' ] | undefined | 'True' +'t' | true | undefined | undefined | [ 't' ] | [ 't' ] | undefined | 't' +'F' | false | undefined | undefined | [ 'F' ] | [ 'F' ] | undefined | 'F' +'false' | false | undefined | undefined | [ 'false' ] | [ 'false' ] | undefined | 'false' +'0b' | undefined | undefined | undefined | [ '0b' ] | [ '0b' ] | undefined | '0b' +'0x' | undefined | undefined | undefined | [ '0x' ] | [ '0x' ] | undefined | '0x' +'abc' | undefined | undefined | undefined | [ 'abc' ] | [ 'abc' ] | undefined | 'abc' +'a' | undefined | undefined | undefined | [ 'a' ] | [ 'a' ] | 'a' | 'a' +'b' | undefined | undefined | undefined | [ 'b' ] | [ 'b' ] | 'b' | 'b' +'a,b' | undefined | undefined | undefined | [ 'a', 'b' ] | [ 'a', 'b' ] | undefined | 'a,b' +'a, b' | undefined | undefined | undefined | [ 'a', ' b' ] | [ 'a', ' b' ] | undefined | 'a, b' +',' | undefined | undefined | undefined | [ '', '' ] | [ '', '' ] | undefined | ',' +'a,,b' | undefined | undefined | undefined | [ 'a', '', 'b' ] | [ 'a', '', 'b' ] | undefined | 'a,,b' +' 1' | undefined | undefined | undefined | [ ' 1' ] | [ ' 1' ] | undefined | ' 1' +0 | false | 0 | 0 | undefined | [ '0' ] | undefined | undefined +1 | true | 1 | 1 | undefined | [ '1' ] | undefined | undefined +-1 | true | undefined | undefined | undefined | [ '-1' ] | undefined | undefined +1.5 | true | undefined | undefined | undefined | [ '1.5' ] | undefined | undefined +NaN | true | undefined | undefined | undefined | [ 'NaN' ] | undefined | undefined +Infinity | true | undefined | undefined | undefined | [ '+Inf' ] | undefined | undefined +-0 | false | -0 | -0 | undefined | [ '-0' ] | undefined | undefined +65535 | true | 65535 | 65535 | undefined | [ '65535' ] | undefined | undefined +65536 | true | 65536 | undefined | undefined | [ '65536' ] | undefined | undefined +9007199254740991 | true | 9007199254740991 | undefined | undefined | [ '9007199254740991' ] | undefined | undefined +9007199254740992 | true | undefined | undefined | undefined | [ '9007199254740992' ] | undefined | undefined +5n | undefined | undefined | undefined | undefined | [ '5' ] | undefined | undefined +true | true | undefined | undefined | undefined | [ '1' ] | undefined | undefined +false | false | undefined | undefined | undefined | [ '0' ] | undefined | undefined +null | undefined | undefined | undefined | undefined | undefined | undefined | undefined +undefined | undefined | undefined | undefined | undefined | undefined | undefined | undefined +{} | undefined | undefined | undefined | undefined | [] | undefined | undefined +{ a: 1 } | undefined | undefined | undefined | undefined | undefined | undefined | undefined +[] | undefined | undefined | undefined | [] | [] | undefined | undefined +[ 'a', 'b' ] | undefined | undefined | undefined | [ 'a', 'b' ] | [ 'a', 'b' ] | undefined | undefined +[ 'a', 1 ] | undefined | undefined | undefined | undefined | [ 'a', '1' ] | undefined | undefined +[ 1, true ] | undefined | undefined | undefined | undefined | [ '1', '1' ] | undefined | undefined +[ {} ] | undefined | undefined | undefined | undefined | undefined | undefined | undefined +[ [] ] | undefined | undefined | undefined | undefined | undefined | undefined | undefined +[ [ 'a' ] ] | undefined | undefined | undefined | undefined | undefined | undefined | undefined +[ null ] | undefined | undefined | undefined | undefined | undefined | undefined | undefined +1e+21 | true | undefined | undefined | undefined | [ '1000000000000000000000' ] | undefined | undefined +1e-7 | true | undefined | undefined | undefined | [ '0.0000001' ] | undefined | undefined +0.1 | true | undefined | undefined | undefined | [ '0.1' ] | undefined | undefined +-1.5e-7 | true | undefined | undefined | undefined | [ '-0.00000015' ] | undefined | undefined`); + }); +}); diff --git a/apps/cli/src/config/cli-config-contract.unit.test.ts b/apps/cli/src/config/cli-config-contract.unit.test.ts new file mode 100644 index 0000000000..75055a3da4 --- /dev/null +++ b/apps/cli/src/config/cli-config-contract.unit.test.ts @@ -0,0 +1,397 @@ +import { CliConfigSchema } from "@supabase/config"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Path, Result, type SchemaAST } from "effect"; + +import { + CLI_CONFIG_CODEC_OVERRIDES, + CLI_CONFIG_CONTEXT_DEFAULTS, + CLI_CONFIG_DOCUMENT_KEYS, + CLI_CONFIG_ENV_EXCLUDED, + CLI_CONFIG_FAMILIES, + CLI_CONFIG_FLAGS, + CLI_CONFIG_LINKED_KEYS, + CLI_CONFIG_NORMALIZERS, + CLI_CONFIG_SCHEMA_EXCLUDED, + CLI_CONFIG_SECTION_ENV_EXEMPT, +} from "./cli-config-key-annotations.ts"; +import { pickCliConfigKey, type CliConfigSources } from "./cli-config-key.ts"; +import { CliConfigKeys, cliConfigFamilyKey, cliConfigRegistry } from "./cli-config-keys.ts"; +import type { AnyCliConfigKey } from "./cli-config-keys.ts"; + +const pathService = Effect.runSync( + Effect.gen(function* () { + return yield* Path.Path; + }).pipe(Effect.provide(Path.layer)), +); + +const CIPHERTEXT = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; +const DECRYPTED = "value"; +const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const ENV_FILE = "/work/supabase/.env"; +const REMOTE = "staging"; + +interface Sample { + readonly env: string; + readonly typed: unknown; +} + +const samplePair = (key: AnyCliConfigKey): readonly [Sample, Sample] => { + switch (key.codec.kind) { + case "bool": + return [ + { env: "true", typed: true }, + { env: "false", typed: false }, + ]; + case "uint": + return [ + { env: "7", typed: 7 }, + { env: "8", typed: 8 }, + ]; + case "port": + return [ + { env: "6001", typed: 6001 }, + { env: "6002", typed: 6002 }, + ]; + case "string": + return [ + { env: "alpha", typed: "alpha" }, + { env: "beta", typed: "beta" }, + ]; + case "commaList": + return [ + { env: "a,b", typed: ["a", "b"] }, + { env: "c", typed: ["c"] }, + ]; + case "literal": { + const literals = (key.codec.literals ?? []).filter((literal) => literal !== ""); + const [first, second] = literals; + if (first === undefined) throw new Error(`${key.path} declares no non-empty literal`); + return [ + { env: first, typed: first }, + { env: second ?? first, typed: second ?? first }, + ]; + } + } +}; + +type Tier = "flag" | "shell" | "projectEnv" | "config"; + +const PRECEDENCE: ReadonlyArray = ["flag", "shell", "projectEnv", "config"]; + +interface Scenario { + readonly winner: Tier; + readonly envName?: string; + readonly sectionPresent: boolean; +} + +const contextFor = (): CliConfigSources["context"] => ({ + workdir: "/work/app", + projectRef: Option.none(), + path: pathService, + configAt: () => undefined, +}); + +/** + * Offers every tier at or below the winner: the winner carries value A and every lower tier + * carries B, so the assertion can only hold if the winner tier actually wins. + */ +const sourcesFor = (key: AnyCliConfigKey, scenario: Scenario): CliConfigSources => { + const [a, b] = samplePair(key); + const secret = key.secret === true; + const rank = PRECEDENCE.indexOf(scenario.winner); + const present = (tier: Tier) => PRECEDENCE.indexOf(tier) >= rank; + const valueFor = (tier: Tier): Sample => { + if (tier !== scenario.winner) return b; + return secret && tier !== "flag" ? { env: CIPHERTEXT, typed: CIPHERTEXT } : a; + }; + const canonical = key.env[0]; + const winnerName = scenario.envName ?? canonical; + + return { + flags: (path) => + path === key.path && present("flag") + ? { flag: `--${path}`, value: valueFor("flag").typed } + : undefined, + shell: (name) => { + if (!present("shell")) return undefined; + if (scenario.winner === "shell") + return name === winnerName ? valueFor("shell").env : undefined; + return name === canonical ? b.env : undefined; + }, + projectEnv: (name) => { + if (!present("projectEnv")) return undefined; + if (scenario.winner === "projectEnv") { + return name === winnerName + ? { value: valueFor("projectEnv").env, file: ENV_FILE } + : undefined; + } + return name === canonical ? { value: b.env, file: ENV_FILE } : undefined; + }, + config: (segments) => { + const path = segments.join("."); + if (path === key.path && present("config")) { + return { + value: valueFor("config").typed, + origin: { path: segments, source: "local" }, + remote: REMOTE, + }; + } + if (path === key.envRequiresSection?.join(".") && scenario.sectionPresent) { + return { value: {}, origin: { path: segments, source: "local" } }; + } + return undefined; + }, + dotenvPrivateKeys: [PRIVATE_KEY], + context: contextFor(), + }; +}; + +const applicable = (key: AnyCliConfigKey, tier: Tier, sectionPresent: boolean): boolean => { + switch (tier) { + case "flag": + return true; + case "shell": + case "projectEnv": + return key.env.length > 0 && (key.envRequiresSection === undefined || sectionPresent); + case "config": + return key.document !== false; + } +}; + +type Expected = + | { readonly tier: Tier; readonly winnerSample: boolean } + | { readonly tier: "default" }; + +const expectedValue = (key: AnyCliConfigKey, tier: Tier, winnerSample: boolean): unknown => { + const [a, b] = samplePair(key); + const sample = winnerSample ? a : b; + const decoded = winnerSample && key.secret === true && tier !== "flag" ? DECRYPTED : sample.typed; + const normalized = key.normalize === undefined ? decoded : key.normalize(decoded, contextFor()); + return key.wrap(normalized); +}; + +const expectedOrigin = (key: AnyCliConfigKey, tier: Tier, envName: string | undefined) => { + switch (tier) { + case "flag": + return { tier: "flag", flag: `--${key.path}` }; + case "shell": + return { tier: "shell", envName }; + case "projectEnv": + return { tier: "projectEnv", envName, file: ENV_FILE }; + case "config": + return { + tier: "config", + origin: { path: key.path.split("."), source: "local" }, + remote: REMOTE, + }; + } +}; + +const subjects: ReadonlyArray<{ readonly label: string; readonly key: AnyCliConfigKey }> = [ + ...cliConfigRegistry.keys.map((key) => ({ label: key.path, key })), + ...CLI_CONFIG_FAMILIES.flatMap((family) => + family.fields.flatMap((field) => { + const key = cliConfigFamilyKey(family, "sample_entry", field.name); + return key === undefined ? [] : [{ label: `${family.id}.${field.name}`, key }]; + }), + ), +]; + +const failureMessages = ( + label: string, + key: AnyCliConfigKey, + scenario: Scenario, + expected: Expected, +): ReadonlyArray => { + const picked = pickCliConfigKey(key, sourcesFor(key, scenario)); + const name = `${label} [${scenario.winner}${scenario.envName === undefined ? "" : ` ${scenario.envName}`}${scenario.sectionPresent ? "" : ", section absent"}]`; + if (Result.isFailure(picked)) return [`${name}: failed with ${picked.failure.message}`]; + const actual = picked.success; + if (expected.tier === "default") { + return actual.origin.tier === "default" + ? [] + : [`${name}: expected default, got ${actual.origin.tier}`]; + } + const envName = expected.winnerSample ? scenario.envName : key.env[0]; + const origin = expectedOrigin(key, expected.tier, envName); + const value = expectedValue(key, expected.tier, expected.winnerSample); + const problems: Array = []; + if (JSON.stringify(actual.origin) !== JSON.stringify(origin)) { + problems.push(`${name}: origin ${JSON.stringify(actual.origin)} != ${JSON.stringify(origin)}`); + } + if (JSON.stringify(actual.value) !== JSON.stringify(value)) { + problems.push(`${name}: value ${JSON.stringify(actual.value)} != ${JSON.stringify(value)}`); + } + return problems; +}; + +const schemaPaths = ( + ast: SchemaAST.AST, + segments: ReadonlyArray = [], + out: Set = new Set(), +): Set => { + const node = ast._tag === "Suspend" ? ast.thunk() : ast; + if (segments.length > 0) out.add(segments.join(".")); + if (node._tag === "Objects") { + for (const property of node.propertySignatures) { + if (typeof property.name === "string") { + schemaPaths(property.type, [...segments, property.name], out); + } + } + } + return out; +}; + +describe("config key contract", () => { + it("covers the whole registry and a sample of every family field", () => { + expect(cliConfigRegistry.keys.length).toBeGreaterThan(150); + const familyFields = CLI_CONFIG_FAMILIES.flatMap((family) => family.fields).length; + expect(subjects.length).toBe(cliConfigRegistry.keys.length + familyFields); + }); + + it("resolves every key from the highest tier that can supply it, with that tier's origin", () => { + const failures = subjects.flatMap(({ label, key }) => { + const sectionStates = key.envRequiresSection === undefined ? [true] : [true, false]; + return sectionStates.flatMap((sectionPresent) => + PRECEDENCE.flatMap((winner): ReadonlyArray => { + if (!applicable(key, winner, sectionPresent)) return []; + const names = winner === "shell" || winner === "projectEnv" ? key.env : [undefined]; + return names.flatMap((envName) => + failureMessages( + label, + key, + { winner, sectionPresent, ...(envName === undefined ? {} : { envName }) }, + { tier: winner, winnerSample: true }, + ), + ); + }), + ); + }); + + expect(failures).toEqual([]); + }); + + it("lets a lower tier win exactly when every higher tier is unavailable for the key", () => { + const failures = subjects.flatMap(({ label, key }) => { + const sectionStates = key.envRequiresSection === undefined ? [true] : [true, false]; + return sectionStates.flatMap((sectionPresent) => + PRECEDENCE.flatMap((tier): ReadonlyArray => { + if (applicable(key, tier, sectionPresent)) return []; + const scenario = { winner: tier, sectionPresent, envName: key.env[0] } as const; + const fallback = PRECEDENCE.slice(PRECEDENCE.indexOf(tier) + 1).find((lower) => + applicable(key, lower, sectionPresent), + ); + return failureMessages( + label, + key, + scenario, + fallback === undefined ? { tier: "default" } : { tier: fallback, winnerSample: false }, + ); + }), + ); + }); + + expect(failures).toEqual([]); + }); + + it("falls through a set-but-empty shell variable to the next applicable tier for every key", () => { + const failures = subjects.flatMap(({ label, key }) => { + const name = key.env[0]; + if (name === undefined) return []; + const [a] = samplePair(key); + const sources: CliConfigSources = { + ...sourcesFor(key, { winner: "config", sectionPresent: true }), + shell: (candidate) => (candidate === name ? "" : undefined), + projectEnv: (candidate) => + candidate === name ? { value: a.env, file: ENV_FILE } : undefined, + }; + const picked = pickCliConfigKey(key, sources); + if (Result.isFailure(picked)) return [`${label}: ${picked.failure.message}`]; + const expectedTier = key.document === false ? "default" : "config"; + const expected = + expectedTier === "default" + ? key.defaultValue(contextFor()) + : expectedValue(key, "config", true); + return picked.success.origin.tier === expectedTier && + JSON.stringify(picked.success.value) === JSON.stringify(expected) + ? [] + : [`${label}: got ${JSON.stringify(picked.success.origin)}`]; + }); + + expect(failures).toEqual([]); + }); + + it("keeps every secret key a string key, so tier decryption applies to all of them", () => { + const secrets = subjects.filter(({ key }) => key.secret === true); + + expect(secrets.length).toBeGreaterThan(5); + expect(secrets.filter(({ key }) => key.codec.kind !== "string")).toEqual([]); + }); +}); + +describe("config key annotations", () => { + const known = schemaPaths(CliConfigSchema.ast); + const rawOnly = new Set( + [...CLI_CONFIG_DOCUMENT_KEYS, ...CLI_CONFIG_LINKED_KEYS].map((def) => def.path), + ); + const exists = (path: string) => known.has(path) || rawOnly.has(path); + + it("annotates only paths that exist in the schema or are declared raw-only", () => { + const annotated: Readonly>> = { + codecOverrides: Object.keys(CLI_CONFIG_CODEC_OVERRIDES), + schemaExcluded: Object.keys(CLI_CONFIG_SCHEMA_EXCLUDED), + envExcluded: Object.keys(CLI_CONFIG_ENV_EXCLUDED), + contextDefaults: Object.keys(CLI_CONFIG_CONTEXT_DEFAULTS), + normalizers: Object.keys(CLI_CONFIG_NORMALIZERS), + flags: Object.keys(CLI_CONFIG_FLAGS), + sectionExempt: Object.keys(CLI_CONFIG_SECTION_ENV_EXEMPT), + }; + + const dangling = Object.entries(annotated).flatMap(([table, paths]) => + paths.filter((path) => !exists(path)).map((path) => `${table}: ${path}`), + ); + + expect(Object.values(annotated).every((paths) => paths.length > 0)).toBe(true); + expect(dangling).toEqual([]); + }); + + it("keeps the local db password out of the environment and apart from the linked password", () => { + const registered = cliConfigRegistry.keyAt("db.password"); + if (registered === undefined) throw new Error("db.password is not in the registry"); + const sources = (shell: Record): CliConfigSources => ({ + ...sourcesFor(registered, { winner: "config", sectionPresent: true }), + flags: () => undefined, + shell: (name) => shell[name], + projectEnv: () => undefined, + config: () => undefined, + }); + const env = { SUPABASE_DB_PASSWORD: "from-env", SUPABASE_DB_PASSWORD_LOCAL: "x" }; + + const local = pickCliConfigKey(CliConfigKeys.db.password, sources(env)); + const linked = pickCliConfigKey(CliConfigKeys.linkedDb.password, sources(env)); + + expect(CliConfigKeys.db.password.env).toEqual([]); + expect(Result.isSuccess(local) && local.success.origin.tier).toBe("default"); + expect(Result.isSuccess(linked) && linked.success).toMatchObject({ + value: Option.some("from-env"), + origin: { tier: "shell", envName: "SUPABASE_DB_PASSWORD" }, + }); + }); + + it("names a real key for every declared flag, carrying its names and aliases", () => { + for (const [path, declaration] of Object.entries(CLI_CONFIG_FLAGS)) { + const key = cliConfigRegistry.keyAt(path); + expect(key?.flagNames).toEqual(declaration.names); + expect(key?.flagAliases).toEqual("aliases" in declaration ? declaration.aliases : []); + } + }); + + it("allows only keys the registry owns to carry flags", () => { + const flagged = cliConfigRegistry.keys + .filter((key) => key.flagNames.length > 0) + .map((key) => key.path); + + expect(flagged.sort()).toEqual(Object.keys(CLI_CONFIG_FLAGS).sort()); + }); +}); diff --git a/apps/cli/src/config/cli-config-document.ts b/apps/cli/src/config/cli-config-document.ts new file mode 100644 index 0000000000..a8ec3d81ce --- /dev/null +++ b/apps/cli/src/config/cli-config-document.ts @@ -0,0 +1,136 @@ +import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; +import { Redacted } from "effect"; + +export const isDocumentRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The value at a path of segments in a parsed config document, or `undefined`. */ +export const getDocumentValue = (document: unknown, segments: ReadonlyArray): unknown => { + let current: unknown = document; + for (const segment of segments) { + if (!isDocumentRecord(current) || !Object.hasOwn(current, segment)) return undefined; + current = current[segment]; + } + return current; +}; + +/** Defines an own data property so a `__proto__` segment never reaches the prototype setter. */ +const defineOwn = (target: Record, key: string, value: unknown): void => { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +}; + +/** Whether two document values are the same plain scalars, arrays or tables. */ +export const sameDocumentValue = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +/** A deep copy of the plain-object and array structure of a parsed document. */ +const cloneDocument = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(cloneDocument); + if (isDocumentRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneDocument(item)]), + ); + } + return value; +}; + +/** {@link cloneDocument} for a table; anything else clones to an empty table. */ +export const cloneDocumentRecord = (value: unknown): Record => { + const copy = cloneDocument(value); + return isDocumentRecord(copy) ? copy : {}; +}; + +/** Writes `value` at a path of segments, creating intermediate tables and replacing non-table values. */ +export const setDocumentValue = ( + document: Record, + segments: ReadonlyArray, + value: unknown, +): void => { + const last = segments[segments.length - 1]; + if (last === undefined) return; + let node = document; + for (const segment of segments.slice(0, -1)) { + const existing = Object.hasOwn(node, segment) ? node[segment] : undefined; + if (isDocumentRecord(existing)) { + node = existing; + continue; + } + const created: Record = {}; + defineOwn(node, segment, created); + node = created; + } + defineOwn(node, last, value); +}; + +/** Every leaf path of a document (arrays and scalars are leaves), dotted. */ +export const documentLeafPaths = ( + value: unknown, + prefix: ReadonlyArray = [], +): Set => { + const out = new Set(); + const visit = (node: unknown, path: ReadonlyArray) => { + if (isDocumentRecord(node)) { + for (const [key, child] of Object.entries(node)) visit(child, [...path, key]); + return; + } + out.add(path.join(".")); + }; + visit(value, prefix); + return out; +}; + +/** Removes the leaves at `paths`, then each table they emptied unless `declared` also holds it. */ +export const pruneDocumentPaths = ( + document: Record, + paths: Iterable>, + declared: unknown, +): void => { + for (const segments of paths) { + const tables: Array> = [document]; + for (const segment of segments.slice(0, -1)) { + const holder = tables.at(-1); + const next = holder !== undefined && Object.hasOwn(holder, segment) ? holder[segment] : null; + if (!isDocumentRecord(next)) break; + tables.push(next); + } + const leaf = segments.at(-1); + const holder = tables.at(-1); + if (tables.length !== segments.length || leaf === undefined || holder === undefined) continue; + delete holder[leaf]; + for (let depth = tables.length - 1; depth > 0; depth--) { + const table = tables[depth]; + const parent = tables[depth - 1]; + const key = segments[depth - 1]; + if (table === undefined || parent === undefined || key === undefined) break; + if (Object.keys(table).length > 0) break; + if (getDocumentValue(declared, segments.slice(0, depth)) !== undefined) break; + delete parent[key]; + } + } +}; + +/** Adds the name of every whole-value `env(NAME)` string in `value`, including inside `Redacted`. */ +export const collectEnvReferences = (value: unknown, out: Set): void => { + if (typeof value === "string") { + const name = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (name !== undefined) out.add(name); + } else if (Redacted.isRedacted(value)) { + collectEnvReferences(Redacted.value(value), out); + } else if (Array.isArray(value)) { + for (const item of value) collectEnvReferences(item, out); + } else if (isDocumentRecord(value)) { + for (const item of Object.values(value)) collectEnvReferences(item, out); + } +}; + +/** The names every whole-value `env(NAME)` string in `trees` refers to. */ +export const envReferenceNames = (...trees: ReadonlyArray): ReadonlySet => { + const names = new Set(); + collectEnvReferences(trees, names); + return names; +}; diff --git a/apps/cli/src/config/cli-config-document.unit.test.ts b/apps/cli/src/config/cli-config-document.unit.test.ts new file mode 100644 index 0000000000..5e9c5c49b1 --- /dev/null +++ b/apps/cli/src/config/cli-config-document.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { getDocumentValue, pruneDocumentPaths, setDocumentValue } from "./cli-config-document.ts"; + +const withCleanPrototype = (body: () => void) => { + try { + body(); + } finally { + delete (Object.prototype as Record)["polluted"]; + delete (Object.prototype as Record)["subject"]; + } +}; + +describe("config document paths", () => { + it("writes a dotted segment under its literal key without touching Object.prototype", () => { + withCleanPrototype(() => { + const document: Record = { template: {} }; + + setDocumentValue(document, ["template", "__proto__.polluted", "subject"], "x"); + + expect(document).toEqual({ template: { "__proto__.polluted": { subject: "x" } } }); + expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); + expect(({} as Record)["polluted"]).toBeUndefined(); + }); + }); + + it("creates an own __proto__ table instead of reaching the prototype", () => { + withCleanPrototype(() => { + const document: Record = {}; + + setDocumentValue(document, ["t", "__proto__", "subject"], "x"); + + const table = document["t"] as Record; + expect(Object.getPrototypeOf(table)).toBe(Object.prototype); + expect(Object.hasOwn(table, "__proto__")).toBe(true); + expect(({} as Record)["subject"]).toBeUndefined(); + }); + }); + + it("replaces an inherited constructor value with an own table", () => { + const document: Record = {}; + + setDocumentValue(document, ["constructor", "prototype", "polluted"], "x"); + + expect(Object.hasOwn(document, "constructor")).toBe(true); + expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); + expect(Object.hasOwn(Object, "polluted")).toBe(false); + }); + + it("reads only own properties", () => { + expect(getDocumentValue({}, ["__proto__", "polluted"])).toBeUndefined(); + expect(getDocumentValue({ "a.b": { c: 1 } }, ["a.b", "c"])).toBe(1); + expect(getDocumentValue({ a: { b: { c: 1 } } }, ["a.b", "c"])).toBeUndefined(); + }); + + it("prunes a leaf without walking into Object.prototype", () => { + withCleanPrototype(() => { + (Object.prototype as Record)["polluted"] = { subject: "keep" }; + const document: Record = {}; + + pruneDocumentPaths(document, [["__proto__", "polluted", "subject"]], {}); + + expect((Object.prototype as Record)["polluted"]).toEqual({ + subject: "keep", + }); + }); + }); + + it("prunes emptied tables unless the declared document holds them", () => { + const document: Record = { a: { b: { c: 1 } }, x: { y: { z: 1 } } }; + + pruneDocumentPaths( + document, + [ + ["a", "b", "c"], + ["x", "y", "z"], + ], + { x: {} }, + ); + + expect(document).toEqual({ x: {} }); + }); +}); diff --git a/apps/cli/src/config/cli-config-flag-ownership.unit.test.ts b/apps/cli/src/config/cli-config-flag-ownership.unit.test.ts new file mode 100644 index 0000000000..9574efc248 --- /dev/null +++ b/apps/cli/src/config/cli-config-flag-ownership.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { rootCommandForFeatures } from "../cli/root.ts"; +import { walkCommandTree } from "../../tests/helpers/command-tree.ts"; +import { CLI_CONFIG_FLAGS } from "./cli-config-key-annotations.ts"; +import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { cliConfigFlagBinding } from "./cli-config-flags.ts"; +import { cliConfigRegistry } from "./cli-config-keys.ts"; + +const declarations: ReadonlyArray<{ path: string; names: ReadonlyArray }> = Object.entries( + CLI_CONFIG_FLAGS, +).map(([path, declaration]) => ({ path, names: declaration.names })); + +const ownerOf = new Map(declarations.flatMap(({ path, names }) => names.map((n) => [n, path]))); + +const commands = walkCommandTree( + rootCommandForFeatures({ stackBackend: "stack", computeEnabled: true }), +); + +const ownedFlags = commands.flatMap((entry) => + entry.flags + .filter((flag) => ownerOf.has(flag.name)) + .map((flag) => ({ ...flag, command: entry.path.join(" "), entry })), +); + +describe("config flag ownership", () => { + it("declares every owned flag against a real registry key", () => { + const unknown = declarations.filter(({ path }) => cliConfigRegistry.keyAt(path) === undefined); + + expect(unknown).toEqual([]); + }); + + it("finds every declared flag name somewhere in the command tree", () => { + const found = new Set(ownedFlags.map((flag) => flag.name)); + const missing = [...ownerOf.keys()].filter((name) => !found.has(name)); + + expect(missing).toEqual([]); + }); + + it("reaches hidden owned flags, so the binding check covers them", () => { + const hidden = ownedFlags.filter((flag) => unwrapParam(flag.param)?.single.hidden === true); + + expect(hidden.length).toBeGreaterThan(0); + }); + + it("binds every command flag the registry owns to its declared key, hidden ones included", () => { + const unbound = ownedFlags.flatMap((flag) => { + const binding = flag.entry.bindings.find((candidate) => candidate.flag === flag.name); + return binding?.path === ownerOf.get(flag.name) && + cliConfigFlagBinding(flag.param)?.path === ownerOf.get(flag.name) + ? [] + : [`${flag.command} --${flag.name}`]; + }); + + expect(unbound).toEqual([]); + }); + + it("annotates no command with a binding the registry does not declare", () => { + const stray = commands.flatMap((entry) => + entry.bindings + .filter((binding) => ownerOf.get(binding.flag) !== binding.path) + .map((binding) => `${entry.path.join(" ")} --${binding.flag} -> ${binding.path}`), + ); + + expect(stray).toEqual([]); + }); +}); diff --git a/apps/cli/src/config/cli-config-flags.ts b/apps/cli/src/config/cli-config-flags.ts new file mode 100644 index 0000000000..ccecb3407b --- /dev/null +++ b/apps/cli/src/config/cli-config-flags.ts @@ -0,0 +1,242 @@ +import { Context, Option } from "effect"; +import { Command, Flag, type Param } from "effect/cli"; + +import { sameDocumentValue } from "./cli-config-document.ts"; +import type { CliConfigCodec, CliConfigKey } from "./cli-config-key.ts"; +import { CliConfigFlagConflictError } from "./cli-config.errors.ts"; + +/** The flag names (and short aliases) a key declares; `key.flag` accepts only these. */ +export interface CliConfigFlagDeclaration { + readonly names: ReadonlyArray; + readonly aliases?: ReadonlyArray; +} + +/** The declaration of a key that no flag overrides. */ +export type CliConfigNoFlags = { readonly names: readonly [] }; + +type FlagAlias = F extends { + readonly aliases: ReadonlyArray; +} + ? Alias + : never; + +/** Another key a flag assigns when it is passed, e.g. `--sql-paths` forcing seeding on. */ +type CliConfigFlagAlso = readonly [key: { readonly path: string }, value: unknown]; + +export interface CliConfigFlagOptions< + X, + F extends CliConfigFlagDeclaration = CliConfigFlagDeclaration, +> { + readonly name: F["names"][number]; + readonly alias?: FlagAlias; + readonly description: string; + /** + * Converts the parsed flag value to the key's value, e.g. `--no-seed` to `enabled = false`. + * Returning `undefined` means the flag assigns nothing, as for a negatable flag left at its + * negative default. + */ + readonly map?: (value: X) => X | undefined; + readonly also?: ReadonlyArray; + /** Hides the flag from help. It must be set here: `Flag.withHidden` on the result drops the binding. */ + readonly hidden?: true; +} + +export interface CliConfigFlagAssignment { + readonly path: string; + readonly flag: string; + readonly value: unknown; +} + +export interface CliConfigFlagBinding { + readonly flag: string; + readonly path: string; + /** Reads the parsed `Option` for this flag; `undefined` when the flag was not passed. */ + readonly assignments: (parsed: unknown) => ReadonlyArray | undefined; +} + +const bindings = new WeakMap(); + +/** The binding a `key.flag(...)` constructor attached to a flag, if it built this one. */ +export const cliConfigFlagBinding = (param: Param.Any): CliConfigFlagBinding | undefined => + bindings.get(param); + +const optionOf = (value: X | undefined): Option.Option => + value === undefined ? Option.none() : Option.some(value); + +const parsedFlag = ( + flag: Flag.Flag, + codec: CliConfigCodec, + name: string, + path: string, +): Flag.Flag> => + flag.pipe( + Flag.filterMap( + (value) => optionOf(codec.fromConfig(value)), + (value) => `Invalid --${name}="${String(value)}" (sets ${path}): expected ${codec.expected}.`, + ), + Flag.optional, + ); + +const baseFlag = ( + codec: CliConfigCodec, + name: string, + path: string, +): Flag.Flag> => { + switch (codec.kind) { + case "bool": + return parsedFlag(Flag.Boolean(name), codec, name, path); + case "uint": + case "port": + return parsedFlag(Flag.Int(name), codec, name, path); + case "literal": + return parsedFlag(Flag.Literals(name, codec.literals ?? []), codec, name, path); + case "string": + return parsedFlag(Flag.String(name), codec, name, path); + case "commaList": + return Flag.String(name).pipe( + Flag.atLeast(0), + Flag.optional, + Flag.map((values) => + Option.flatMap(values, (list) => + list.length === 0 ? Option.none() : optionOf(codec.fromConfig(list)), + ), + ), + ); + } +}; + +/** Builds the `Flag.optional` flag for `key` and records its binding. Used by `key.flag(...)`. */ +export const makeCliConfigKeyFlag = ( + key: CliConfigKey, + options: CliConfigFlagOptions, +): Flag.Flag> => { + const described = baseFlag(key.codec, options.name, key.path).pipe( + Flag.withDescription(options.description), + ); + const aliased = + options.alias === undefined ? described : described.pipe(Flag.withAlias(options.alias)); + const flag = options.hidden === true ? aliased.pipe(Flag.withHidden) : aliased; + bindings.set(flag, { + flag: options.name, + path: key.path, + assignments: (parsed) => { + if (!Option.isOption(parsed) || Option.isNone(parsed)) return undefined; + if (key.codec.kind === "string" && parsed.value === "") return undefined; + const decoded = key.codec.fromConfig(parsed.value); + if (decoded === undefined) return undefined; + const value = options.map === undefined ? decoded : options.map(decoded); + if (value === undefined) return undefined; + return [ + { path: key.path, flag: options.name, value }, + ...(options.also ?? []).map(([other, otherValue]) => ({ + path: other.path, + flag: options.name, + value: otherValue, + })), + ]; + }, + }); + return flag; +}; + +/** Two flags that assigned different values to one config key. */ +interface CliConfigFlagConflict { + readonly path: string; + readonly flags: readonly [string, string]; +} + +interface CliConfigFlagInputsValue { + /** The explicitly passed flags that bind to config keys, keyed by key path. */ + readonly assignments: ReadonlyMap; + /** Raised as {@link CliConfigFlagConflictError} by the first `CliConfigValues.load`. */ + readonly conflicts: ReadonlyArray; +} + +export class CliConfigFlagInputs extends Context.Service< + CliConfigFlagInputs, + CliConfigFlagInputsValue +>()("supabase/cli/CliConfigFlagInputs") {} + +export const cliConfigFlagConflictError = (conflict: CliConfigFlagConflict) => + new CliConfigFlagConflictError({ + path: conflict.path, + flags: conflict.flags, + message: `--${conflict.flags[0]} and --${conflict.flags[1]} both set ${conflict.path}; pass only one`, + }); + +/** The flag inputs for a set of assignments; two with different values for one path conflict. */ +export const makeCliConfigFlagInputs = ( + assignments: Iterable = [], +): CliConfigFlagInputsValue => { + const inputs = new Map(); + const conflicts: Array = []; + for (const assignment of assignments) { + const prior = inputs.get(assignment.path); + if (prior === undefined) { + inputs.set(assignment.path, assignment); + } else if (!sameDocumentValue(prior.value, assignment.value)) { + conflicts.push({ path: assignment.path, flags: [prior.flag, assignment.flag] }); + } + } + return { assignments: inputs, conflicts }; +}; + +/** Command annotation listing the bound flags, so a tree walk can find them. */ +export class CliConfigFlagBindings extends Context.Service< + CliConfigFlagBindings, + ReadonlyArray +>()("supabase/cli/CliConfigFlagBindings") {} + +interface BoundParam { + readonly accessor: ReadonlyArray; + readonly binding: CliConfigFlagBinding; +} + +const collectBound = ( + node: unknown, + accessor: ReadonlyArray, + found: Array, +): void => { + if (typeof node !== "object" || node === null) return; + const binding = bindings.get(node); + if (binding !== undefined) { + found.push({ accessor, binding }); + return; + } + if (Array.isArray(node)) { + node.forEach((child, index) => collectBound(child, [...accessor, index], found)); + return; + } + for (const [name, child] of Object.entries(node)) { + collectBound(child, [...accessor, name], found); + } +}; + +const readAt = (input: unknown, accessor: ReadonlyArray): unknown => + accessor.reduce( + (node, segment) => + typeof node === "object" && node !== null ? Reflect.get(node, segment) : undefined, + input, + ); + +/** + * Provides {@link CliConfigFlagInputs} from the parsed command input and annotates the command with + * its bound flags. `config` is the same record passed to `Command.make`. + */ +export const withCliConfigFlags = (config: C) => { + const bound: Array = []; + collectBound(config, [], bound); + const annotated = bound.map((entry) => entry.binding); + + return ( + self: Command.Command, ContextInput, E, R>, + ) => + self.pipe( + Command.provideSync(CliConfigFlagInputs, (input: Command.Command.Config.Infer) => + makeCliConfigFlagInputs( + bound.flatMap((entry) => entry.binding.assignments(readAt(input, entry.accessor)) ?? []), + ), + ), + Command.annotate(CliConfigFlagBindings, annotated), + ); +}; diff --git a/apps/cli/src/config/cli-config-flags.unit.test.ts b/apps/cli/src/config/cli-config-flags.unit.test.ts new file mode 100644 index 0000000000..c4a919a888 --- /dev/null +++ b/apps/cli/src/config/cli-config-flags.unit.test.ts @@ -0,0 +1,270 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Console, Context, Effect, Layer, Option } from "effect"; +import { CliOutput, Command, Flag } from "effect/cli"; + +import { fakeConsole } from "../../tests/helpers/mocks.ts"; +import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { jsonCliOutputFormatter } from "../shared/output/json-formatter.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { + CliConfigFlagBindings, + CliConfigFlagInputs, + cliConfigFlagBinding, + cliConfigFlagConflictError, + withCliConfigFlags, +} from "./cli-config-flags.ts"; +import { portCodec, requiredCliConfigKey } from "./cli-config-key.ts"; +import { CliConfigKeys } from "./cli-config-keys.ts"; + +const config = { + noSeed: CliConfigKeys.db.seed.enabled.flag({ + name: "no-seed", + description: "Skip seeding.", + map: (skip) => (skip ? false : undefined), + }), + includeSeed: CliConfigKeys.db.seed.enabled.flag({ + name: "include-seed", + description: "Seed.", + }), + proxyPort: requiredCliConfigKey({ + path: "probe.port", + codec: portCodec, + default: 1, + flags: { names: ["proxy-port"] }, + }).flag({ name: "proxy-port", description: "Port." }), + sqlPaths: CliConfigKeys.db.seed.sqlPaths.flag({ + name: "sql-paths", + description: "Seed files.", + also: [[CliConfigKeys.db.seed.enabled, true]], + }), + password: CliConfigKeys.linkedDb.password.flag({ + name: "password", + alias: "p", + description: "Database password.", + }), + usePgDelta: CliConfigKeys.experimental.pgdelta.enabled.flag({ + name: "use-pg-delta", + description: "Use pg-delta.", + hidden: true, + }), + unrelated: Flag.String("unrelated").pipe(Flag.optional), +} as const; + +const runInputs = (args: ReadonlyArray) => + Effect.gen(function* () { + const seen: Array = []; + const command = Command.make("probe", config).pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* CliConfigFlagInputs); + }), + ), + withCliConfigFlags(config), + ); + yield* Command.runWith(command, { version: "0.0.0-test" })(args).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(textCliOutputFormatter()))), + ); + const inputs = seen[0]; + if (inputs === undefined) throw new Error("handler did not run"); + return inputs; + }).pipe(Effect.scoped); + +const run = (args: ReadonlyArray) => + runInputs(args).pipe( + Effect.map((inputs) => + Object.fromEntries( + [...inputs.assignments].map(([path, assignment]) => [path, assignment.value]), + ), + ), + ); + +describe("key.flag", () => { + it.effect("reports nothing when no bound flag is passed", () => + Effect.gen(function* () { + expect(yield* run([])).toEqual({}); + }), + ); + + it.effect("maps an inverted flag to the key's value", () => + Effect.gen(function* () { + expect(yield* run(["--no-seed"])).toEqual({ "db.seed.enabled": false }); + }), + ); + + it.effect("treats a negatable flag at its negative default as no assignment", () => + Effect.gen(function* () { + expect(yield* run(["--no-seed=false"])).toEqual({}); + }), + ); + + it.effect("passes a positive flag value through unchanged", () => + Effect.gen(function* () { + expect(yield* run(["--include-seed"])).toEqual({ "db.seed.enabled": true }); + }), + ); + + it.effect("records two flags that assign different values to one key as a conflict", () => + Effect.gen(function* () { + const inputs = yield* runInputs(["--sql-paths", "a.sql", "--no-seed"]); + + expect(inputs.conflicts).toEqual([ + { path: "db.seed.enabled", flags: ["no-seed", "sql-paths"] }, + ]); + expect(cliConfigFlagConflictError(inputs.conflicts[0]!).message).toBe( + "--no-seed and --sql-paths both set db.seed.enabled; pass only one", + ); + }), + ); + + it.effect("does not conflict when two flags agree on a key", () => + Effect.gen(function* () { + const inputs = yield* runInputs(["--sql-paths", "a.sql", "--include-seed"]); + + expect(inputs.conflicts).toEqual([]); + }), + ); + + it.effect("rejects an out-of-range port as a parse error instead of dropping it", () => + Effect.gen(function* () { + expect(yield* run(["--proxy-port", "8080"])).toEqual({ "probe.port": 8080 }); + const error = yield* run(["--proxy-port", "70000"]).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "ShowHelp", errors: [{ _tag: "InvalidValue" }] }); + expect(JSON.stringify(error)).toContain("70000"); + }), + ); + + it.effect("assigns the paired keys of a multi-assign flag", () => + Effect.gen(function* () { + expect(yield* run(["--sql-paths", "a.sql", "--sql-paths", "b/*.sql"])).toEqual({ + "db.seed.sql_paths": ["a.sql", "b/*.sql"], + "db.seed.enabled": true, + }); + }), + ); + + it.effect("binds a flag through its alias to a key without a document path", () => + Effect.gen(function* () { + expect(yield* run(["-p", "s3cret"])).toEqual({ "linkedDb.password": "s3cret" }); + }), + ); + + it.effect("treats an empty string flag value as unset", () => + Effect.gen(function* () { + expect(yield* run(["-p", ""])).toEqual({}); + }), + ); + + it.effect("distinguishes an explicit false from an absent boolean flag", () => + Effect.gen(function* () { + expect(yield* run(["--use-pg-delta=false"])).toEqual({ + "experimental.pgdelta.enabled": false, + }); + expect(yield* run([])).toEqual({}); + }), + ); + + it("hides a flag while keeping its binding", () => { + expect(unwrapParam(config.usePgDelta)?.single.hidden).toBe(true); + expect(unwrapParam(config.password)?.single.hidden).toBeFalsy(); + expect(cliConfigFlagBinding(config.usePgDelta)).toMatchObject({ + flag: "use-pg-delta", + path: "experimental.pgdelta.enabled", + }); + }); + + it.effect("ignores flags that are not bound to a key", () => + Effect.gen(function* () { + expect(yield* run(["--unrelated", "x"])).toEqual({}); + }), + ); + + it("attaches a binding to each key flag and none to other flags", () => { + expect(cliConfigFlagBinding(config.noSeed)).toMatchObject({ + flag: "no-seed", + path: "db.seed.enabled", + }); + expect(cliConfigFlagBinding(config.unrelated)).toBeUndefined(); + }); + + it("stays introspectable for telemetry and completion", () => { + expect(unwrapParam(config.password)).toMatchObject({ + single: { name: "password", aliases: ["p"] }, + isOptional: true, + }); + expect(unwrapParam(config.sqlPaths)).toMatchObject({ + single: { name: "sql-paths" }, + isVariadic: true, + }); + expect(unwrapParam(config.noSeed)?.single.name).toBe("no-seed"); + }); + + it("annotates the command with its bound flags", () => { + const command = Command.make("probe", config).pipe(withCliConfigFlags(config)); + + const bindings = Context.getOption(command.annotations, CliConfigFlagBindings); + + expect( + Option.getOrElse(bindings, () => []) + .map((binding) => binding.flag) + .sort(), + ).toEqual(["include-seed", "no-seed", "password", "proxy-port", "sql-paths", "use-pg-delta"]); + }); +}); + +describe("flag typing", () => { + const requiresNothing = ( + command: Command.Command, + ) => command; + const readInputs = Command.make("probe", config).pipe( + Command.withHandler(() => CliConfigFlagInputs.use(() => Effect.void)), + ); + + it("accepts only the flag names a key declares", () => { + CliConfigKeys.db.seed.enabled.flag({ + // @ts-expect-error -- "skip-seed" is not a declared flag for db.seed.enabled + name: "skip-seed", + description: "Not declared.", + }); + // @ts-expect-error -- api.port declares no flags + CliConfigKeys.api.port.flag({ name: "port", description: "Not declared." }); + + expect(cliConfigFlagBinding(config.noSeed)?.flag).toBe("no-seed"); + }); + + it("leaves CliConfigFlagInputs required until withCliConfigFlags provides it", () => { + // @ts-expect-error -- the handler still requires CliConfigFlagInputs + requiresNothing(readInputs); + requiresNothing(readInputs.pipe(withCliConfigFlags(config))); + + expect(true).toBe(true); + }); +}); + +describe("config-bound flags in machine-readable help", () => { + it.effect("omits the binding annotation and marks a variadic flag optional", () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const command = Command.make("probe", config).pipe( + Command.withHandler(() => Effect.void), + withCliConfigFlags(config), + ); + + yield* Command.runWith(command, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + CliOutput.layer(jsonCliOutputFormatter()), + Layer.succeed(Console.Console, console), + ), + ), + ); + + const help = JSON.parse(calls[0]?.replace(/^log:/, "") ?? "{}"); + const sqlPaths = help.doc.flags.find((flag: { name: string }) => flag.name === "sql-paths"); + expect(help.doc).not.toHaveProperty("annotations"); + expect(sqlPaths).toMatchObject({ required: false }); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/cli/src/config/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts new file mode 100644 index 0000000000..f4183202bb --- /dev/null +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -0,0 +1,322 @@ +import { + DEFAULT_LOCAL_DATABASE_PASSWORD, + DEFAULT_POSTGRES_ROOT_KEY, +} from "@supabase/stack/defaults"; + +import { sanitizeProjectId } from "../shared/config/project-id.ts"; +import { resolveSeedSqlPath } from "../shared/config/seed-path.ts"; +import type { CliConfigFlagDeclaration } from "./cli-config-flags.ts"; +import { + commaListCodec, + globListCodec, + boolCodec, + literalCodec, + stringCodec, + type CliConfigCodec, + type CliConfigKeyContext, +} from "./cli-config-key.ts"; + +/** A hand-written registry entry for a key the schema walk cannot produce. */ +export interface CliConfigKeyDef { + readonly path: string; + /** The document path as segments when a segment may itself contain `.`; defaults to `path` split on `.`. */ + readonly segments?: ReadonlyArray; + readonly codec: CliConfigCodec; + /** Consumers read `Option`; absent keys default to `None`. */ + readonly optional?: true; + readonly default?: unknown; + readonly defaultFrom?: (ctx: CliConfigKeyContext) => unknown; + /** `[0]` canonical; omitted derives `SUPABASE_` + UPPER_SNAKE(path). */ + readonly env?: ReadonlyArray; + /** Never overridable from the environment. */ + readonly noEnv?: true; + readonly secret?: true; + /** Not a config document path: the document is never consulted. */ + readonly document?: false; + readonly envScope?: "linkedTarget"; + /** `materialize` writes the default into the decoded config when no tier supplies a value. */ + readonly materializeDefault?: true; +} + +/** Schema leaves whose env decoding differs from the schema-derived codec. */ +export const CLI_CONFIG_CODEC_OVERRIDES: Readonly>> = { + "db.seed.sql_paths": globListCodec, + "db.migrations.schema_paths": globListCodec, + "edge_runtime.policy": literalCodec(["per_worker", "oneshot"]), + "auth.password_requirements": literalCodec([ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols", + ]), +}; + +/** + * Schema leaves the registry cannot encode as a key, with the reason. Registry construction throws + * for any other leaf without a codec. + */ +export const CLI_CONFIG_SCHEMA_EXCLUDED: Readonly> = { + "experimental.inspect.rules": "a list of tables, not strings; read from the document", +}; + +/** Schema leaves that are not env-overridable, with the reason. */ +export const CLI_CONFIG_ENV_EXCLUDED: Readonly> = { + "experimental.orioledb_version": "deprecated; promoted to db.orioledb_version before resolution", +}; + +const emptyString = () => ""; + +/** + * Optional schema leaves that consumers read as a plain value with a default: the project id + * derives from the workdir, and each auth hook's `uri` and `secrets` read as `""`. + */ +export const CLI_CONFIG_CONTEXT_DEFAULTS = { + project_id: (ctx) => ctx.path.basename(ctx.workdir), + "auth.hook.mfa_verification_attempt.uri": emptyString, + "auth.hook.mfa_verification_attempt.secrets": emptyString, + "auth.hook.password_verification_attempt.uri": emptyString, + "auth.hook.password_verification_attempt.secrets": emptyString, + "auth.hook.custom_access_token.uri": emptyString, + "auth.hook.custom_access_token.secrets": emptyString, + "auth.hook.send_sms.uri": emptyString, + "auth.hook.send_sms.secrets": emptyString, + "auth.hook.send_email.uri": emptyString, + "auth.hook.send_email.secrets": emptyString, + "auth.hook.before_user_created.uri": emptyString, + "auth.hook.before_user_created.secrets": emptyString, +} as const satisfies Readonly unknown>>; + +/** Canonical flag names (and short aliases) that override a key; `key.flag` accepts only these. */ +export const CLI_CONFIG_FLAGS = { + "linkedDb.password": { names: ["password"], aliases: ["p"] }, + "db.seed.enabled": { names: ["include-seed", "no-seed"] }, + "db.seed.sql_paths": { names: ["sql-paths"] }, + "experimental.pgdelta.enabled": { names: ["use-pg-delta"] }, +} as const satisfies Readonly>; + +const prefixed = (ctx: CliConfigKeyContext, pattern: unknown): unknown => + typeof pattern === "string" ? resolveSeedSqlPath(ctx.path, pattern) : pattern; + +/** Schema leaves whose resolved value is the raw one made relative to `supabase/`. */ +export const CLI_CONFIG_NORMALIZERS: Readonly< + Record unknown> +> = { + project_id: (value) => (typeof value === "string" ? sanitizeProjectId(value) : value), + "db.seed.sql_paths": (value, ctx) => + Array.isArray(value) ? value.map((item) => prefixed(ctx, item)) : value, + "db.migrations.schema_paths": (value, ctx) => + Array.isArray(value) ? value.map((item) => prefixed(ctx, item)) : value, + "experimental.pgdelta.declarative_schema_path": (value, ctx) => prefixed(ctx, value), +}; + +const STATIC_SECTION_GATES: ReadonlyArray> = [ + ["experimental", "webhooks"], + ["storage", "image_transformation"], + ["db", "ssl_enforcement"], + ["auth", "captcha"], + ["auth", "email", "smtp"], + ["auth", "passkey"], + ["auth", "webauthn"], +]; + +const SMS_PROVIDER_SECTIONS: ReadonlySet = new Set([ + "twilio_verify", + "messagebird", + "textlocal", + "vonage", +]); + +/** Optional schema sections whose env overrides apply while the section is absent, with the reason. */ +export const CLI_CONFIG_SECTION_ENV_EXEMPT: Readonly> = { + "auth.sessions": + "a plain struct in the document model: its keys exist whether or not it is written", + "db.settings": + "a plain struct in the document model: its keys exist whether or not it is written", + "experimental.pgdelta": + "enabled defaults to true without the section, so the env rollback must apply while it is absent", +}; + +/** The optional section a key's env override requires, when its path sits under one. */ +export const envRequiresSectionFor = ( + segments: ReadonlyArray, +): ReadonlyArray | undefined => { + for (const gate of STATIC_SECTION_GATES) { + if (segments.length > gate.length && gate.every((part, index) => segments[index] === part)) { + return gate; + } + } + const [root, group, name] = segments; + if (name === undefined || segments.length < 4) return undefined; + const dynamic = + (root === "auth" && group === "hook") || + (root === "auth" && group === "sms" && SMS_PROVIDER_SECTIONS.has(name)) || + (root === "auth" && group === "external" && name !== "apple"); + return dynamic ? [root, group, name] : undefined; +}; + +/** Keys read from the raw document that the schema does not model. */ +export const CLI_CONFIG_DOCUMENT_KEYS: ReadonlyArray = [ + { + path: "db.password", + codec: stringCodec, + default: DEFAULT_LOCAL_DATABASE_PASSWORD, + noEnv: true, + }, + { + path: "db.root_key", + codec: stringCodec, + default: DEFAULT_POSTGRES_ROOT_KEY, + secret: true, + }, + { path: "auth.external_url", codec: stringCodec, optional: true }, + { path: "auth.passkey.enabled", codec: boolCodec, default: false }, + { path: "auth.webauthn.rp_id", codec: stringCodec, default: "" }, + { path: "auth.webauthn.rp_display_name", codec: stringCodec, default: "" }, + { path: "auth.webauthn.rp_origins", codec: commaListCodec, default: [] }, +]; + +/** Keys with no document path: the linked project's database password. */ +export const CLI_CONFIG_LINKED_KEYS: ReadonlyArray = [ + { + path: "linkedDb.password", + codec: stringCodec, + optional: true, + env: ["SUPABASE_DB_PASSWORD"], + document: false, + envScope: "linkedTarget", + }, +]; + +interface CliConfigFamilyField { + readonly name: string; + readonly codec: CliConfigCodec; + readonly optional?: true; + readonly default?: unknown; + readonly secret?: true; +} + +export type CliConfigFamilyId = + | "authExternal" + | "authEmailTemplate" + | "authEmailNotification" + | "authHook"; + +export interface CliConfigFamilyDef { + readonly id: CliConfigFamilyId; + /** Dotted path of the table whose entries are named by the family. */ + readonly prefix: string; + readonly fields: ReadonlyArray; +} + +/** Tables keyed by an arbitrary name; env names are `SUPABASE_` + the path with the name upper-cased. */ +export const CLI_CONFIG_FAMILIES: ReadonlyArray = [ + { + id: "authExternal", + prefix: "auth.external", + fields: [ + { name: "enabled", codec: boolCodec, default: false }, + { name: "client_id", codec: stringCodec, default: "" }, + { name: "secret", codec: stringCodec, optional: true, secret: true }, + { name: "url", codec: stringCodec, default: "" }, + { name: "redirect_uri", codec: stringCodec, default: "" }, + { name: "skip_nonce_check", codec: boolCodec, default: false }, + { name: "email_optional", codec: boolCodec, default: false }, + ], + }, + { + id: "authEmailTemplate", + prefix: "auth.email.template", + fields: [ + { name: "subject", codec: stringCodec, optional: true }, + { name: "content_path", codec: stringCodec, default: "" }, + { name: "content", codec: stringCodec, optional: true }, + ], + }, + { + id: "authEmailNotification", + prefix: "auth.email.notification", + fields: [ + { name: "enabled", codec: boolCodec, default: false }, + { name: "subject", codec: stringCodec, optional: true }, + { name: "content_path", codec: stringCodec, default: "" }, + { name: "content", codec: stringCodec, optional: true }, + ], + }, + { + id: "authHook", + prefix: "auth.hook", + fields: [ + { name: "enabled", codec: boolCodec, default: false }, + { name: "uri", codec: stringCodec, default: "" }, + { name: "secrets", codec: stringCodec, default: "", secret: true }, + ], + }, +]; + +/** + * `SUPABASE_*` variables the CLI reads that are not config overrides, with their owner. A derived + * config env name must never equal one of these. + */ +export const CLI_NON_CONFIG_ENV_NAMES: Readonly> = { + SUPABASE_ACCESS_TOKEN: "platform auth", + SUPABASE_ACTIVE_HELP: "shell completion", + SUPABASE_ANON_KEY: "functions runtime env", + SUPABASE_API_URL: "platform profile", + SUPABASE_BASELINE: "pgdata snapshot marker file name", + SUPABASE_CA_SKIP_VERIFY: "telemetry signal", + SUPABASE_CLI_BINARY_OVERRIDE: "launcher", + SUPABASE_CLI_VERSION: "build-time define", + SUPABASE_CLI_POSTHOG_HOST: "telemetry", + SUPABASE_CLI_POSTHOG_KEY: "telemetry", + SUPABASE_COMPLETION_DESCRIPTIONS: "shell completion", + SUPABASE_DASHBOARD_URL: "platform profile", + SUPABASE_DB_URL: "functions runtime env", + SUPABASE_DEBUG: "global flag", + SUPABASE_ENV: "dotenv file selector", + SUPABASE_EXPERIMENTAL: "global flag", + SUPABASE_FOO_BAR: "documentation example", + SUPABASE_FUNCTIONS_DIR: "functions deploy constant", + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: "functions serve", + SUPABASE_FUNCTION_SLUG: "functions runtime env", + SUPABASE_HOME: "global state directory", + SUPABASE_INSTALL_METHOD: "upgrade notice", + SUPABASE_INTERNAL_DEBUG: "internal", + SUPABASE_INTERNAL_FUNCTIONS_CONFIG: "internal", + SUPABASE_INTERNAL_HOST_PORT: "internal", + SUPABASE_INTERNAL_IMAGE_REGISTRY: "internal", + SUPABASE_INTERNAL_JWT_SECRET: "internal", + SUPABASE_INTERNAL_PUBLISHABLE_KEY: "internal", + SUPABASE_INTERNAL_SECRET_KEY: "internal", + SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC: "internal", + SUPABASE_JWKS: "functions runtime env", + SUPABASE_JWT_SECRET: "functions runtime env", + SUPABASE_NETWORK_ID: "global flag", + SUPABASE_NO_KEYRING: "credential storage", + SUPABASE_NO_UPDATE_NOTIFIER: "upgrade notice", + SUPABASE_OTLP_ENDPOINT: "telemetry", + SUPABASE_OTLP_HEADERS: "telemetry", + SUPABASE_POSTGRES_URL: "functions runtime env", + SUPABASE_PROFILE: "platform profile", + SUPABASE_PROJECT_HOST: "platform profile", + SUPABASE_PUBLIC_URL: "functions runtime env", + SUPABASE_PUBLISHABLE_KEY: "functions runtime env", + SUPABASE_PUBLISHABLE_KEYS: "functions runtime env", + SUPABASE_SCANNER_BUFFER_SIZE: "seed scanner", + SUPABASE_SECRET_KEY: "functions runtime env", + SUPABASE_SECRET_KEYS: "functions runtime env", + SUPABASE_SERVICES_HOSTNAME: "functions runtime env", + SUPABASE_SERVICE_KEY: "functions runtime env", + SUPABASE_SERVICE_ROLE_KEY: "functions runtime env", + SUPABASE_SHADOW_CACHE: "shadow database cache", + SUPABASE_SHADOW_DEBUG: "shadow database", + SUPABASE_SSL_DEBUG: "telemetry signal", + SUPABASE_TELEMETRY_DEBUG: "telemetry", + SUPABASE_TELEMETRY_DISABLED: "telemetry", + SUPABASE_TELEMETRY_POSTHOG_HOST: "telemetry", + SUPABASE_TELEMETRY_POSTHOG_KEY: "telemetry", + SUPABASE_TRACE_FILE: "telemetry", + SUPABASE_URL: "functions runtime env", + SUPABASE_USE_SLIM_IMAGES: "image selection", + SUPABASE_WORKDIR: "global flag", + SUPABASE_YES: "global flag", +}; diff --git a/apps/cli/src/config/cli-config-key.ts b/apps/cli/src/config/cli-config-key.ts new file mode 100644 index 0000000000..9f2fd221e2 --- /dev/null +++ b/apps/cli/src/config/cli-config-key.ts @@ -0,0 +1,580 @@ +import type { CliConfigValueOrigin } from "@supabase/config"; +import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; +import { Option, Result, Schema, type Path } from "effect"; +import type { Flag } from "effect/cli"; + +import { decryptSecret, isEncryptedSecret } from "../shared/config/vault-decrypt.ts"; +import { + boolSchemas, + commaListSchemas, + globEntries, + globListSchemas, + literalSchemas, + portSchemas, + stringSchemas, + uintSchemas, + unsupportedTypeName, + type CliConfigCodecSchemas, +} from "./cli-config-codec-schemas.ts"; +import { + makeCliConfigKeyFlag, + type CliConfigFlagDeclaration, + type CliConfigFlagOptions, + type CliConfigNoFlags, +} from "./cli-config-flags.ts"; +import { CliConfigValueError } from "./cli-config.errors.ts"; + +export type CliConfigTier = "flag" | "shell" | "projectEnv" | "config" | "default"; + +export type CliConfigKeyOrigin = + | { readonly tier: "flag"; readonly flag: string } + | { + readonly tier: "shell" | "projectEnv"; + readonly envName: string; + readonly file?: string; + } + | { + readonly tier: "config"; + readonly origin: CliConfigValueOrigin; + readonly remote?: string; + readonly file?: string; + } + | { readonly tier: "default" }; + +export interface CliConfigValue { + readonly value: A; + readonly origin: CliConfigKeyOrigin; + /** The decoded value before the key's `normalize`; present only when the key normalizes. */ + readonly unnormalized?: A; +} + +export interface CliConfigKeyContext { + readonly workdir: string; + readonly projectRef: Option.Option; + readonly path: Path.Path; + /** The merged raw document value at a dotted path. */ + readonly configAt: (path: string) => unknown; +} + +interface CliConfigFlagValue { + readonly flag: string; + readonly value: unknown; +} + +interface CliConfigConfigValue { + readonly value: unknown; + readonly origin: CliConfigValueOrigin; + readonly remote?: string; + /** The absolute path of the config file the value was read from. */ + readonly file?: string; +} + +/** What `load(target)` offers each tier; a tier that is absent here simply cannot win. */ +export interface CliConfigSources { + readonly flags: (path: string) => CliConfigFlagValue | undefined; + /** `undefined` means unset; an empty string is a set-but-empty variable. */ + readonly shell: (name: string) => string | undefined; + readonly projectEnv: ( + name: string, + ) => { readonly value: string; readonly file?: string } | undefined; + readonly config: (segments: ReadonlyArray) => CliConfigConfigValue | undefined; + readonly dotenvPrivateKeys: ReadonlyArray; + readonly context: CliConfigKeyContext; +} + +type CliConfigCodecKind = "bool" | "uint" | "port" | "string" | "commaList" | "literal"; + +export interface CliConfigCodec { + readonly kind: CliConfigCodecKind; + readonly literals?: ReadonlyArray; + /** Decodes an env or flag string; `undefined` means invalid. */ + readonly parse: (raw: string) => X | undefined; + /** Decodes a typed document or flag value; `undefined` means invalid. */ + readonly fromConfig: (value: unknown) => X | undefined; + /** What a valid value is, completing "expected …" and "… is not …" in error messages. */ + readonly expected: string; + /** Per-entry failures of an invalid document value, reported together across keys. */ + readonly issues?: (path: string, value: unknown) => ReadonlyArray; +} + +export const decodingFailedMessage = (issues: ReadonlyArray): string => + `failed to parse config:\n${issues.join("\n")}`; + +const quoted = (values: ReadonlyArray) => values.map((value) => `"${value}"`).join(", "); + +const codecFromSchemas = (spec: { + readonly kind: CliConfigCodecKind; + readonly expected: string; + readonly schemas: CliConfigCodecSchemas; + readonly literals?: ReadonlyArray; + readonly issues?: CliConfigCodec["issues"]; +}): CliConfigCodec => { + const decodeString = Schema.decodeUnknownOption(spec.schemas.fromString); + const decodeDocument = Schema.decodeUnknownOption(spec.schemas.fromDocument); + return { + kind: spec.kind, + ...(spec.literals === undefined ? {} : { literals: spec.literals }), + parse: (raw) => Option.getOrUndefined(decodeString(raw)), + fromConfig: (value) => Option.getOrUndefined(decodeDocument(value)), + expected: spec.expected, + ...(spec.issues === undefined ? {} : { issues: spec.issues }), + }; +}; + +export const boolCodec = codecFromSchemas({ + kind: "bool", + expected: "true or false", + schemas: boolSchemas, +}); + +export const uintCodec = codecFromSchemas({ + kind: "uint", + expected: "a non-negative integer", + schemas: uintSchemas, +}); + +export const portCodec = codecFromSchemas({ + kind: "port", + expected: "a port (0-65535)", + schemas: portSchemas, +}); + +export const stringCodec = codecFromSchemas({ + kind: "string", + expected: "a string", + schemas: stringSchemas, +}); + +/** Comma-separated list: no trimming, and an empty string is the empty list. */ +export const commaListCodec = codecFromSchemas({ + kind: "commaList", + expected: "a comma-separated list", + schemas: commaListSchemas, +}); + +/** + * A glob list that decodes weakly: a bare string is comma-split, a scalar or an array entry that + * is a number or bool becomes its text, and an empty table is the empty list. Nested lists, tables + * and datetimes are invalid. + */ +export const globListCodec = codecFromSchemas({ + kind: "commaList", + expected: "a comma-separated list", + schemas: globListSchemas, + issues: (path, value) => + (globEntries(value) ?? []).flatMap((entry, index) => { + const type = unsupportedTypeName(entry); + return type === undefined ? [] : [`${path}[${index}]: expected a string, got ${type}`]; + }), +}); + +export const literalCodec = (values: ReadonlyArray): CliConfigCodec => + codecFromSchemas({ + kind: "literal", + literals: values, + expected: `one of ${quoted(values)}`, + schemas: literalSchemas(values), + }); + +export interface CliConfigKeySpec { + readonly path: string; + /** The document path as segments when a segment may itself contain `.`; defaults to `path` split on `.`. */ + readonly segments?: ReadonlyArray; + /** `[0]` is the canonical name; the rest are deprecated aliases. Empty means not env-overridable. */ + readonly env?: ReadonlyArray; + readonly codec: CliConfigCodec; + readonly secret?: true; + readonly normalize?: (value: X, ctx: CliConfigKeyContext) => X; + /** Env tiers apply only while this section (as path segments) is present in the merged document. */ + readonly envRequiresSection?: ReadonlyArray; + /** Marks a credential for the linked target; the loader may withhold its env sources. */ + readonly envScope?: "linkedTarget"; + /** `false` when the key has no document path, so the config tier never applies. */ + readonly document?: false; + /** `materialize` writes the default into the decoded config when no tier supplies a value. */ + readonly materializeDefault?: true; + /** The flag names and aliases that may override this key. */ + readonly flags?: F; +} + +/** + * A config value descriptor: `A` is the value consumers read, `X` the decoded leaf (they differ + * only for optional keys, where `A` is `Option`), and `F` the flag names it declares. + */ +export interface CliConfigKey { + readonly path: string; + /** The document path; a dynamic table name stays one segment even if it contains `.`. */ + readonly segments: ReadonlyArray; + readonly env: ReadonlyArray; + readonly codec: CliConfigCodec; + /** The default-tier value; a context-dependent default reads the merged document. */ + readonly defaultValue: (ctx: CliConfigKeyContext) => A; + readonly wrap: (value: X) => A; + readonly toDocument: (value: A) => unknown; + readonly secret?: true; + readonly normalize?: (value: X, ctx: CliConfigKeyContext) => X; + readonly envRequiresSection?: ReadonlyArray; + readonly envScope?: "linkedTarget"; + readonly document?: false; + readonly materializeDefault?: true; + readonly flagNames: ReadonlyArray; + readonly flagAliases: ReadonlyArray; + readonly flag: (options: CliConfigFlagOptions) => Flag.Flag>; +} + +/** The primitive behind the typed key factories; the registry uses it to build type-erased keys. */ +export const makeCliConfigKey = ( + spec: CliConfigKeySpec, + shape: Pick, "defaultValue" | "wrap" | "toDocument">, +): CliConfigKey => { + const key: CliConfigKey = { + path: spec.path, + segments: spec.segments ?? spec.path.split("."), + env: spec.env ?? [], + codec: spec.codec, + ...shape, + ...(spec.secret === undefined ? {} : { secret: spec.secret }), + ...(spec.normalize === undefined ? {} : { normalize: spec.normalize }), + ...(spec.envRequiresSection === undefined + ? {} + : { envRequiresSection: spec.envRequiresSection }), + ...(spec.envScope === undefined ? {} : { envScope: spec.envScope }), + ...(spec.document === undefined ? {} : { document: spec.document }), + ...(spec.materializeDefault === undefined + ? {} + : { materializeDefault: spec.materializeDefault }), + flagNames: spec.flags?.names ?? [], + flagAliases: spec.flags?.aliases ?? [], + flag: (options) => makeCliConfigKeyFlag(key, options), + }; + return key; +}; + +/** A key with a value in every resolution: its default stands in when no tier supplies one. */ +export const requiredCliConfigKey = < + X, + const F extends CliConfigFlagDeclaration = CliConfigNoFlags, +>( + spec: CliConfigKeySpec & + ( + | { readonly default: X; readonly defaultFrom?: undefined } + | { readonly default?: undefined; readonly defaultFrom: (ctx: CliConfigKeyContext) => X } + ), +): CliConfigKey => { + const { default: fixed, defaultFrom } = spec; + return makeCliConfigKey(spec, { + defaultValue: defaultFrom ?? (() => fixed), + wrap: (value) => value, + toDocument: (value) => value, + }); +}; + +/** A key that may be absent; consumers read `Option`. */ +export const optionalCliConfigKey = < + X, + const F extends CliConfigFlagDeclaration = CliConfigNoFlags, +>( + spec: CliConfigKeySpec & { + readonly defaultFrom?: (ctx: CliConfigKeyContext) => Option.Option; + }, +): CliConfigKey, X, F> => + makeCliConfigKey, X, F>(spec, { + defaultValue: spec.defaultFrom ?? (() => Option.none()), + wrap: Option.some, + toDocument: Option.getOrUndefined, + }); + +/** An env-name-only read: shell-only, no config path and no tier list. */ +export interface CliEnvName { + readonly name: string; + readonly codec: CliConfigCodec; + /** The config key whose canonical env name this read shares, if any. */ + readonly configKeyPath?: string; +} + +export const cliEnvName = (entry: CliEnvName): CliEnvName => entry; + +/** The non-empty value of an env name, shell before project `.env*`; a set-but-empty shell name shadows the file. */ +export const lookupCliConfigEnv = ( + sources: Pick, + name: string, +): string | undefined => { + const shell = sources.shell(name); + if (shell !== undefined) return shell === "" ? undefined : shell; + const file = sources.projectEnv(name)?.value; + return file === undefined || file === "" ? undefined : file; +}; + +/** Replaces an `env(NAME)` string with the named variable when it resolves to a non-empty value. */ +export const expandCliConfigEnvReference = ( + value: string, + lookup: (name: string) => string | undefined, +): string => { + const name = ENV_CAPTURE_REGEX.exec(value)?.[1]; + return name === undefined ? value : (lookup(name) ?? value); +}; + +const expandConfigValue = ( + value: unknown, + lookup: (name: string) => string | undefined, +): unknown => { + if (typeof value === "string") return expandCliConfigEnvReference(value, lookup); + if (Array.isArray(value)) { + return value.map((item) => + typeof item === "string" ? expandCliConfigEnvReference(item, lookup) : item, + ); + } + return value; +}; + +interface RedactionSubject { + readonly secret?: true; + readonly codec: { readonly kind: CliConfigCodecKind }; +} + +const describeTomlType = (value: unknown): string => { + if (typeof value === "number" || typeof value === "bigint") return "a number"; + if (typeof value === "boolean") return "a boolean"; + if (Array.isArray(value)) return "an array"; + if (value instanceof Date) return "a date-time"; + return typeof value === "object" && value !== null ? "a table" : typeof value; +}; + +/** A string codec's parse is the identity, so it fails only on type and the value is never echoed. */ +const isTypeOnlyFailure = (key: RedactionSubject): boolean => + key.codec.kind === "string" && key.secret !== true; + +const display = (value: unknown, key: RedactionSubject): string => { + if (key.secret === true) return ""; + if (isTypeOnlyFailure(key)) return describeTomlType(value); + return typeof value === "string" ? value : (JSON.stringify(value) ?? String(value)); +}; + +const displayFile = (ctx: Pick, file: string): string => + ctx.path.relative(ctx.workdir, file) || file; + +/** Where a value came from, in the words error and warning messages use. */ +export const describeCliConfigOrigin = ( + origin: CliConfigKeyOrigin, + ctx: Pick, +): string => { + switch (origin.tier) { + case "flag": + return `--${origin.flag}`; + case "shell": + return `${origin.envName} (shell)`; + case "projectEnv": + return origin.file === undefined + ? `${origin.envName} (project env file)` + : `${origin.envName} (${displayFile(ctx, origin.file)})`; + case "config": { + const file = origin.file === undefined ? undefined : displayFile(ctx, origin.file); + if (origin.remote === undefined) return file ?? "config"; + return file === undefined + ? `[remotes.${origin.remote}]` + : `[remotes.${origin.remote}] (${file})`; + } + case "default": + return "default"; + } +}; + +interface CliConfigValueFailure { + readonly path: string; + readonly raw: string; + readonly expected: string; + readonly origin: Exclude; + /** `raw` names the value's type rather than quoting it. */ + readonly typeOnly?: true; +} + +/** The error for a value a codec rejected; the message names the source that supplied it. */ +const invalidCliConfigValue = ( + failure: CliConfigValueFailure, + ctx: Pick, +): CliConfigValueError => { + const { path, raw, expected, origin, typeOnly } = failure; + const source = describeCliConfigOrigin(origin, ctx); + switch (origin.tier) { + case "flag": + return new CliConfigValueError({ + path, + tier: "flag", + source, + flag: origin.flag, + message: `Invalid --${origin.flag}="${raw}" (sets ${path}): expected ${expected}.`, + }); + case "shell": + case "projectEnv": { + const file = + origin.tier === "projectEnv" && origin.file !== undefined + ? ` in ${displayFile(ctx, origin.file)}` + : ""; + return new CliConfigValueError({ + path, + tier: origin.tier, + source, + envName: origin.envName, + message: `Invalid ${origin.envName}="${raw}"${file} (sets ${path}): expected ${expected}.`, + }); + } + case "config": { + const where = describeCliConfigOrigin(origin, ctx); + return new CliConfigValueError({ + path, + tier: "config", + source, + message: typeOnly + ? `Invalid ${path}${where === "config" ? "" : ` in ${where}`}: expected ${expected}, got ${raw}.` + : where === "config" + ? `Invalid ${path}: "${raw}" is not ${expected}.` + : `Invalid ${path} in ${where}: "${raw}" is not ${expected}.`, + }); + } + } +}; + +/** + * The single precedence implementation: flag > shell > projectEnv > config > default. Pure; the + * caller decides which sources exist. `configOnly` skips the flag and env tiers. + */ +export const pickCliConfigKey = ( + key: CliConfigKey, + sources: CliConfigSources, + options: { readonly configOnly?: true } = {}, +): Result.Result, CliConfigValueError> => { + const lookup = (name: string) => lookupCliConfigEnv(sources, name); + const secret = key.secret === true; + const typeOnly = isTypeOnlyFailure(key); + + const failure = ( + origin: Exclude, + raw: unknown, + ) => + Result.fail( + invalidCliConfigValue( + { + path: key.path, + raw: display(raw, key), + expected: key.codec.expected, + origin, + ...(typeOnly ? { typeOnly } : {}), + }, + sources.context, + ), + ); + + const decrypt = ( + tier: CliConfigTier, + raw: unknown, + ): Result.Result => { + if (!secret || typeof raw !== "string" || !isEncryptedSecret(raw)) return Result.succeed(raw); + const decrypted = decryptSecret(raw, sources.dotenvPrivateKeys); + return decrypted.ok + ? Result.succeed(decrypted.value) + : Result.fail( + new CliConfigValueError({ + path: key.path, + tier, + message: `failed to parse config: ${decrypted.error}`, + }), + ); + }; + + const settle = (decoded: X, origin: CliConfigKeyOrigin): CliConfigValue => { + const normalized = + key.normalize === undefined ? decoded : key.normalize(decoded, sources.context); + return { + value: key.wrap(normalized), + origin, + ...(key.normalize === undefined ? {} : { unnormalized: key.wrap(decoded) }), + }; + }; + + const readEnv = (tier: "shell" | "projectEnv", name: string) => { + const shell = sources.shell(name); + if (tier === "shell") return shell === undefined ? undefined : { value: shell }; + return shell === undefined ? sources.projectEnv(name) : undefined; + }; + + const flag = options.configOnly === true ? undefined : sources.flags(key.path); + if (flag !== undefined) { + const origin = { tier: "flag", flag: flag.flag } as const; + const decoded = key.codec.fromConfig(flag.value); + if (decoded === undefined) return failure(origin, flag.value); + return Result.succeed(settle(decoded, origin)); + } + + const envAllowed = + key.envRequiresSection === undefined || sources.config(key.envRequiresSection) !== undefined; + if (envAllowed && options.configOnly !== true) { + for (const tier of ["shell", "projectEnv"] as const) { + for (const name of key.env) { + const found = readEnv(tier, name); + if (found === undefined || found.value === "") continue; + + const file = "file" in found ? found.file : undefined; + const origin: CliConfigKeyOrigin = + file === undefined ? { tier, envName: name } : { tier, envName: name, file }; + const raw = expandCliConfigEnvReference(found.value, lookup); + const plain = decrypt(tier, raw); + if (Result.isFailure(plain)) return Result.fail(plain.failure); + const decoded = key.codec.parse(typeof plain.success === "string" ? plain.success : raw); + if (decoded === undefined) return failure(origin, raw); + return Result.succeed(settle(decoded, origin)); + } + } + } + + const configured = key.document === false ? undefined : sources.config(key.segments); + if (configured !== undefined) { + const origin: CliConfigKeyOrigin = { + tier: "config", + origin: configured.origin, + ...(configured.remote === undefined ? {} : { remote: configured.remote }), + ...(configured.file === undefined ? {} : { file: configured.file }), + }; + const plain = decrypt("config", expandConfigValue(configured.value, lookup)); + if (Result.isFailure(plain)) return Result.fail(plain.failure); + const decoded = key.codec.fromConfig(plain.success); + if (decoded === undefined) { + const issues = key.codec.issues?.(key.path, plain.success) ?? []; + return issues.length === 0 + ? failure(origin, plain.success) + : Result.fail( + new CliConfigValueError({ + path: key.path, + tier: "config", + source: describeCliConfigOrigin(origin, sources.context), + message: decodingFailedMessage(issues), + issues, + }), + ); + } + return Result.succeed(settle(decoded, origin)); + } + + return Result.succeed({ value: key.defaultValue(sources.context), origin: { tier: "default" } }); +}; + +/** Reads an env-name-only entry from the shell; an empty value counts as unset. */ +export const pickCliEnvName = ( + entry: CliEnvName, + sources: Pick, +): Result.Result, CliConfigValueError> => { + const raw = sources.shell(entry.name); + if (raw === undefined || raw === "") return Result.succeed(Option.none()); + const decoded = entry.codec.parse(raw); + const path = entry.configKeyPath ?? entry.name; + return decoded === undefined + ? Result.fail( + new CliConfigValueError({ + path, + tier: "shell", + source: `${entry.name} (shell)`, + message: `Invalid ${entry.name}="${raw}" (sets ${path}): expected ${entry.codec.expected}.`, + envName: entry.name, + }), + ) + : Result.succeed(Option.some(decoded)); +}; diff --git a/apps/cli/src/config/cli-config-key.unit.test.ts b/apps/cli/src/config/cli-config-key.unit.test.ts new file mode 100644 index 0000000000..3ee4ffd240 --- /dev/null +++ b/apps/cli/src/config/cli-config-key.unit.test.ts @@ -0,0 +1,569 @@ +import { Effect, Option, Path, Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + commaListCodec, + boolCodec, + globListCodec, + uintCodec, + literalCodec, + optionalCliConfigKey, + pickCliConfigKey, + pickCliEnvName, + portCodec, + requiredCliConfigKey, + stringCodec, + cliEnvName, + type CliConfigKey, + type CliConfigSources, +} from "./cli-config-key.ts"; +import { CLI_CONFIG_FAMILIES } from "./cli-config-key-annotations.ts"; +import { cliConfigFamilyKey, cliConfigRegistry, type AnyCliConfigKey } from "./cli-config-keys.ts"; + +const pathService = Effect.runSync( + Effect.gen(function* () { + return yield* Path.Path; + }).pipe(Effect.provide(Path.layer)), +); + +interface SourceParts { + readonly flags?: Readonly>; + readonly shell?: Readonly>; + readonly projectEnv?: Readonly>; + readonly config?: Readonly>; + readonly dotenvPrivateKeys?: ReadonlyArray; +} + +const makeSources = (parts: SourceParts = {}): CliConfigSources => { + const config = parts.config ?? {}; + return { + flags: (path) => + parts.flags !== undefined && path in parts.flags + ? { flag: `--${path}`, value: parts.flags[path] } + : undefined, + shell: (name) => parts.shell?.[name], + projectEnv: (name) => { + const value = parts.projectEnv?.[name]; + return value === undefined ? undefined : { value, file: "/work/supabase/.env" }; + }, + config: (segments) => { + const path = segments.join("."); + return path in config + ? { value: config[path], origin: { path: segments, source: "local" } } + : undefined; + }, + dotenvPrivateKeys: parts.dotenvPrivateKeys ?? [], + context: { + workdir: "/work/app", + projectRef: Option.none(), + path: pathService, + configAt: (path) => config[path], + }, + }; +}; + +const seed = requiredCliConfigKey({ + path: "db.seed.enabled", + env: ["SUPABASE_DB_SEED_ENABLED"], + codec: boolCodec, + default: true, +}); + +const valueOf = (key: CliConfigKey, parts?: SourceParts) => { + const picked = pickCliConfigKey(key, makeSources(parts)); + if (Result.isFailure(picked)) throw picked.failure; + return picked.success; +}; + +const failureOf = (key: CliConfigKey, parts?: SourceParts) => { + const picked = pickCliConfigKey(key, makeSources(parts)); + if (Result.isSuccess(picked)) throw new Error("expected a failure"); + return picked.failure; +}; + +describe("pickCliConfigKey tiers", () => { + const all: SourceParts = { + flags: { "db.seed.enabled": false }, + shell: { SUPABASE_DB_SEED_ENABLED: "true" }, + projectEnv: { SUPABASE_DB_SEED_ENABLED: "false" }, + config: { "db.seed.enabled": "true" }, + }; + + it("resolves flag over shell over project env over config over default", () => { + const withoutFlag = { ...all, flags: {} }; + const withoutShell = { ...withoutFlag, shell: {} }; + const withoutProject = { ...withoutShell, projectEnv: {} }; + const withoutConfig = { ...withoutProject, config: {} }; + + expect(valueOf(seed, all)).toMatchObject({ value: false, origin: { tier: "flag" } }); + expect(valueOf(seed, withoutFlag)).toMatchObject({ + value: true, + origin: { tier: "shell", envName: "SUPABASE_DB_SEED_ENABLED" }, + }); + expect(valueOf(seed, withoutShell)).toMatchObject({ + value: false, + origin: { + tier: "projectEnv", + envName: "SUPABASE_DB_SEED_ENABLED", + file: "/work/supabase/.env", + }, + }); + expect(valueOf(seed, withoutProject)).toMatchObject({ + value: true, + origin: { tier: "config" }, + }); + expect(valueOf(seed, withoutConfig)).toEqual({ value: true, origin: { tier: "default" } }); + }); + + it("lets a set-but-empty shell variable shadow the project env and fall through to config", () => { + const picked = valueOf(seed, { + shell: { SUPABASE_DB_SEED_ENABLED: "" }, + projectEnv: { SUPABASE_DB_SEED_ENABLED: "false" }, + config: { "db.seed.enabled": true }, + }); + + expect(picked).toMatchObject({ value: true, origin: { tier: "config" } }); + }); + + it("treats an empty project env value as absent", () => { + const picked = valueOf(seed, { + projectEnv: { SUPABASE_DB_SEED_ENABLED: "" }, + config: { "db.seed.enabled": false }, + }); + + expect(picked).toMatchObject({ value: false, origin: { tier: "config" } }); + }); + + it("wraps an absent optional key as None", () => { + const key = optionalCliConfigKey({ + path: "db.orioledb_version", + env: ["SUPABASE_DB_ORIOLEDB_VERSION"], + codec: stringCodec, + }); + + expect(valueOf(key).value).toEqual(Option.none()); + expect(valueOf(key, { shell: { SUPABASE_DB_ORIOLEDB_VERSION: "15.1" } }).value).toEqual( + Option.some("15.1"), + ); + }); + + it("evaluates a context default against the loaded document", () => { + const smtp = requiredCliConfigKey({ + path: "auth.email.smtp.enabled", + codec: boolCodec, + defaultFrom: (ctx) => ctx.configAt("auth.email.smtp") !== undefined, + }); + + expect(valueOf(smtp).value).toBe(false); + expect(valueOf(smtp, { config: { "auth.email.smtp": {} } }).value).toBe(true); + }); +}); + +describe("pickCliConfigKey env expansion", () => { + it("expands one env() level at the shell tier from the project env", () => { + const picked = valueOf(seed, { + shell: { SUPABASE_DB_SEED_ENABLED: "env(SEED)" }, + projectEnv: { SEED: "false" }, + }); + + expect(picked.value).toBe(false); + }); + + it("expands one env() level at the project env tier from the shell", () => { + const picked = valueOf(seed, { + projectEnv: { SUPABASE_DB_SEED_ENABLED: "env(SEED)" }, + shell: { SEED: "false" }, + }); + + expect(picked.value).toBe(false); + }); + + it("does not expand a second level", () => { + const failure = failureOf(seed, { + shell: { SUPABASE_DB_SEED_ENABLED: "env(A)", A: "env(B)", B: "true" }, + }); + + expect(failure.message).toContain('Invalid SUPABASE_DB_SEED_ENABLED="env(B)"'); + }); + + it("keeps an unresolved env() literal and reports it", () => { + const failure = failureOf(seed, { shell: { SUPABASE_DB_SEED_ENABLED: "env(MISSING)" } }); + + expect(failure.tier).toBe("shell"); + expect(failure.message).toContain('Invalid SUPABASE_DB_SEED_ENABLED="env(MISSING)"'); + }); + + it("resolves a config-tier env() reference with a lowercase name", () => { + const picked = valueOf(seed, { + config: { "db.seed.enabled": "env(seed_flag)" }, + projectEnv: { seed_flag: "false" }, + }); + + expect(picked.value).toBe(false); + }); + + it("keeps the config-tier literal when the referenced variable is set but empty", () => { + const failure = failureOf(seed, { + config: { "db.seed.enabled": "env(SEED)" }, + shell: { SEED: "" }, + }); + + expect(failure.tier).toBe("config"); + }); +}); + +describe("pickCliConfigKey attributes", () => { + const poolMode = requiredCliConfigKey({ + path: "db.pooler.pool_mode", + env: ["SUPABASE_DB_POOLER_POOL_MODE"], + codec: literalCodec(["transaction", "session"]), + default: "transaction", + }); + + it("reports the env name that supplied the value, with the first name winning when several are set", () => { + const poolSize = requiredCliConfigKey({ + path: "db.pooler.default_pool_size", + env: ["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", "SUPABASE_DB_POOLER_POOL_SIZE"], + codec: uintCodec, + default: 20, + }); + + expect(valueOf(poolSize, { shell: { SUPABASE_DB_POOLER_POOL_SIZE: "5" } })).toMatchObject({ + value: 5, + origin: { tier: "shell", envName: "SUPABASE_DB_POOLER_POOL_SIZE" }, + }); + const both = valueOf(poolSize, { + shell: { + SUPABASE_DB_POOLER_POOL_SIZE: "5", + SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE: "8", + }, + }); + expect(both.value).toBe(8); + expect(both.origin).toEqual({ + tier: "shell", + envName: "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", + }); + }); + + it("applies envRequiresSection only to the env tiers", () => { + const webhooks = requiredCliConfigKey({ + path: "experimental.webhooks.enabled", + env: ["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"], + codec: boolCodec, + default: false, + envRequiresSection: ["experimental", "webhooks"], + }); + const shell = { SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "true" }; + + expect(valueOf(webhooks, { shell }).origin.tier).toBe("default"); + expect(valueOf(webhooks, { shell, config: { "experimental.webhooks": {} } })).toMatchObject({ + value: true, + origin: { tier: "shell" }, + }); + expect( + valueOf(webhooks, { flags: { "experimental.webhooks.enabled": true } }).origin.tier, + ).toBe("flag"); + }); + + it("decrypts the winning secret at every tier", () => { + const ciphertext = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + const dotenvPrivateKeys = ["7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"]; + const secret = optionalCliConfigKey({ + path: "auth.captcha.secret", + env: ["SUPABASE_AUTH_CAPTCHA_SECRET"], + codec: stringCodec, + secret: true, + }); + + expect( + valueOf(secret, { shell: { SUPABASE_AUTH_CAPTCHA_SECRET: ciphertext }, dotenvPrivateKeys }) + .value, + ).toEqual(Option.some("value")); + expect( + valueOf(secret, { config: { "auth.captcha.secret": ciphertext }, dotenvPrivateKeys }).value, + ).toEqual(Option.some("value")); + }); + + it("fails a secret that cannot be decrypted without echoing the ciphertext", () => { + const secret = optionalCliConfigKey({ + path: "auth.captcha.secret", + env: ["SUPABASE_AUTH_CAPTCHA_SECRET"], + codec: stringCodec, + secret: true, + }); + + const failure = failureOf(secret, { + shell: { SUPABASE_AUTH_CAPTCHA_SECRET: "encrypted:abcd" }, + }); + + expect(failure.message).toBe("failed to parse config: missing private key"); + }); + + it("normalizes the winner and keeps the unnormalized value", () => { + const sqlPaths = requiredCliConfigKey({ + path: "db.seed.sql_paths", + env: ["SUPABASE_DB_SEED_SQL_PATHS"], + codec: commaListCodec, + default: ["./seed.sql"], + normalize: (value, ctx) => value.map((entry) => ctx.path.join("supabase", entry)), + }); + + const picked = valueOf(sqlPaths, { shell: { SUPABASE_DB_SEED_SQL_PATHS: "a.sql,b.sql" } }); + + expect(picked.value).toEqual(["supabase/a.sql", "supabase/b.sql"]); + expect(picked.unnormalized).toEqual(["a.sql", "b.sql"]); + }); + + it("splits a comma list without trimming and reads an empty config string as an empty list", () => { + const schemas = requiredCliConfigKey({ + path: "api.schemas", + env: ["SUPABASE_API_SCHEMAS"], + codec: commaListCodec, + default: ["public"], + }); + + expect(valueOf(schemas, { shell: { SUPABASE_API_SCHEMAS: "a, b" } }).value).toEqual([ + "a", + " b", + ]); + expect(valueOf(schemas, { config: { "api.schemas": "" } }).value).toEqual([]); + }); + + it("accepts a flag value of the key's type and rejects another", () => { + expect(valueOf(poolMode, { flags: { "db.pooler.pool_mode": "session" } }).value).toBe( + "session", + ); + expect(failureOf(poolMode, { flags: { "db.pooler.pool_mode": "other" } }).tier).toBe("flag"); + }); + + it("never consults the document for a key without a document path", () => { + const password = optionalCliConfigKey({ + path: "linkedDb.password", + env: ["SUPABASE_DB_PASSWORD"], + codec: stringCodec, + document: false, + }); + + expect(valueOf(password, { config: { "linkedDb.password": "from-config" } }).origin.tier).toBe( + "default", + ); + }); +}); + +describe("pickCliConfigKey failure text", () => { + const dbPassword = (): AnyCliConfigKey => { + const key = cliConfigRegistry.keyAt("db.password"); + if (key === undefined) throw new Error("db.password is not registered"); + return key; + }; + + it("never echoes a non-string db.password and names the TOML type instead", () => { + const numeric = failureOf(dbPassword(), { config: { "db.password": 98765432 } }); + const listed = failureOf(dbPassword(), { config: { "db.password": ["98765432"] } }); + + expect(numeric.message).toBe("Invalid db.password: expected a string, got a number."); + expect(numeric.message).not.toContain("98765432"); + expect(listed.message).toContain("got an array"); + expect(listed.message).not.toContain("98765432"); + }); + + it("never echoes the config value of any string-codec key", () => { + const stringKeys: Array = [ + ...cliConfigRegistry.keys.filter( + (key) => key.codec.kind === "string" && key.document !== false, + ), + ...CLI_CONFIG_FAMILIES.flatMap((family) => + family.fields.flatMap((field) => + field.codec.kind === "string" + ? [cliConfigFamilyKey(family, "custom", field.name) ?? []] + : [], + ), + ).flat(), + ]; + + expect(stringKeys.length).toBeGreaterThan(10); + for (const key of stringKeys) { + const picked = pickCliConfigKey(key, makeSources({ config: { [key.path]: 123456789 } })); + if (Result.isSuccess(picked)) throw new Error(`${key.path} accepted a number`); + expect(picked.failure.message, key.path).not.toContain("123456789"); + } + }); + + it("still shows the rejected value of a non-string codec", () => { + const port = requiredCliConfigKey({ + path: "api.port", + env: ["SUPABASE_API_PORT"], + codec: portCodec, + default: 54321, + }); + + expect(failureOf(port, { config: { "api.port": "abc" } }).message).toContain('"abc"'); + }); + + it("names the env variable, the key and the expected bool", () => { + const failure = failureOf(seed, { shell: { SUPABASE_DB_SEED_ENABLED: "maybe" } }); + + expect(failure.message).toBe( + 'Invalid SUPABASE_DB_SEED_ENABLED="maybe" (sets db.seed.enabled): expected true or false.', + ); + }); + + it("names the expected range for a port out of range", () => { + const port = requiredCliConfigKey({ + path: "api.port", + env: ["SUPABASE_API_PORT"], + codec: portCodec, + default: 54321, + }); + + const failure = failureOf(port, { shell: { SUPABASE_API_PORT: "70000" } }); + + expect(failure.message).toBe( + 'Invalid SUPABASE_API_PORT="70000" (sets api.port): expected a port (0-65535).', + ); + }); + + it("rejects a malformed uint and keeps the base-prefix grammar", () => { + const jwtExpiry = requiredCliConfigKey({ + path: "auth.jwt_expiry", + env: ["SUPABASE_AUTH_JWT_EXPIRY"], + codec: uintCodec, + default: 3600, + }); + + expect(failureOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "08" } }).message).toBe( + 'Invalid SUPABASE_AUTH_JWT_EXPIRY="08" (sets auth.jwt_expiry): expected a non-negative integer.', + ); + expect(valueOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "0x10" } }).value).toBe(16); + expect(valueOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "010" } }).value).toBe(8); + expect(valueOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "1_000" } }).value).toBe(1000); + }); + + it("caps a uint at the largest safe integer instead of rounding", () => { + const jwtExpiry = requiredCliConfigKey({ + path: "auth.jwt_expiry", + env: ["SUPABASE_AUTH_JWT_EXPIRY"], + codec: uintCodec, + default: 3600, + }); + + expect( + valueOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "9007199254740991" } }).value, + ).toBe(Number.MAX_SAFE_INTEGER); + expect( + failureOf(jwtExpiry, { shell: { SUPABASE_AUTH_JWT_EXPIRY: "9007199254740993" } }).message, + ).toBe( + 'Invalid SUPABASE_AUTH_JWT_EXPIRY="9007199254740993" (sets auth.jwt_expiry): expected a non-negative integer.', + ); + expect( + failureOf(jwtExpiry, { config: { "auth.jwt_expiry": "9007199254740993" } }).message, + ).toBe('Invalid auth.jwt_expiry: "9007199254740993" is not a non-negative integer.'); + }); + + it("lists the allowed values of an enum", () => { + const backend = requiredCliConfigKey({ + path: "analytics.backend", + env: ["SUPABASE_ANALYTICS_BACKEND"], + codec: literalCodec(["postgres", "bigquery"]), + default: "postgres", + }); + + const failure = failureOf(backend, { shell: { SUPABASE_ANALYTICS_BACKEND: "sqlite" } }); + + expect(failure.message).toBe( + 'Invalid SUPABASE_ANALYTICS_BACKEND="sqlite" (sets analytics.backend): expected one of "postgres", "bigquery".', + ); + }); + + it("does not echo a secret value", () => { + const secretBool = requiredCliConfigKey({ + path: "x.secret", + env: ["SUPABASE_X_SECRET"], + codec: boolCodec, + default: false, + secret: true, + }); + + expect( + failureOf(secretBool, { shell: { SUPABASE_X_SECRET: "hunter2" } }).message, + ).not.toContain("hunter2"); + }); +}); + +describe("portCodec", () => { + it.each(["08080", "00", "0123", "070000"])("rejects the leading-zero decimal %s", (raw) => { + expect(portCodec.parse(raw)).toBeUndefined(); + }); + + it.each([ + ["0", 0], + ["8080", 8080], + ["0x1F90", 8080], + ["0o17", 15], + ["0b101", 5], + ])("accepts %s", (raw, port) => { + expect(portCodec.parse(raw)).toBe(port); + }); +}); + +describe("pickCliConfigKey weak config values", () => { + it("reads a case-variant bool token and a numeric bool from the document", () => { + expect(valueOf(seed, { config: { "db.seed.enabled": "TRUE" } })).toMatchObject({ + value: true, + origin: { tier: "config" }, + }); + expect(valueOf(seed, { config: { "db.seed.enabled": 0 } }).value).toBe(false); + }); + + const sqlPaths = requiredCliConfigKey({ + path: "db.seed.sql_paths", + env: ["SUPABASE_DB_SEED_SQL_PATHS"], + codec: globListCodec, + default: ["supabase/seed.sql"], + }); + const globOf = (config: unknown) => + valueOf(sqlPaths, { config: { "db.seed.sql_paths": config } }).value; + + it("decodes a glob list from a bare string, a scalar, a mixed array and an empty table", () => { + expect(globOf("a.sql,b.sql")).toEqual(["a.sql", "b.sql"]); + expect(globOf(true)).toEqual(["1"]); + expect(globOf(["a.sql", 1, false, 1e21])).toEqual([ + "a.sql", + "1", + "0", + "1000000000000000000000", + ]); + expect(globOf([Number.POSITIVE_INFINITY, Number.NaN])).toEqual(["+Inf", "NaN"]); + expect(globOf({})).toEqual([]); + }); + + it("names every unsupported entry in one decoding-failed error", () => { + const failure = failureOf(sqlPaths, { + config: { "db.seed.sql_paths": [["nested"], "ok", { k: "v" }] }, + }); + + expect(failure).toMatchObject({ tier: "config", path: "db.seed.sql_paths" }); + expect(failure.issues).toEqual([ + "db.seed.sql_paths[0]: expected a string, got array", + "db.seed.sql_paths[2]: expected a string, got table", + ]); + expect(failure.message).toBe(`failed to parse config:\n${failure.issues?.join("\n")}`); + }); +}); + +describe("pickCliEnvName", () => { + const projectId = cliEnvName({ name: "SUPABASE_PROJECT_ID", codec: stringCodec }); + + it("reads the shell only and treats empty as unset", () => { + expect(Result.getOrThrow(pickCliEnvName(projectId, { shell: () => "abc" }))).toEqual( + Option.some("abc"), + ); + expect(Result.getOrThrow(pickCliEnvName(projectId, { shell: () => "" }))).toEqual( + Option.none(), + ); + expect(Result.getOrThrow(pickCliEnvName(projectId, { shell: () => undefined }))).toEqual( + Option.none(), + ); + }); +}); diff --git a/apps/cli/src/config/cli-config-keys.ts b/apps/cli/src/config/cli-config-keys.ts new file mode 100644 index 0000000000..4d6314f723 --- /dev/null +++ b/apps/cli/src/config/cli-config-keys.ts @@ -0,0 +1,387 @@ +import { CliConfigSchema, type CliConfig } from "@supabase/config"; +import { Option, type SchemaAST } from "effect"; + +import { + CLI_CONFIG_CODEC_OVERRIDES, + CLI_CONFIG_CONTEXT_DEFAULTS, + CLI_CONFIG_DOCUMENT_KEYS, + CLI_CONFIG_ENV_EXCLUDED, + CLI_CONFIG_FAMILIES, + CLI_CONFIG_FLAGS, + CLI_CONFIG_LINKED_KEYS, + CLI_CONFIG_NORMALIZERS, + CLI_CONFIG_SCHEMA_EXCLUDED, + envRequiresSectionFor, + type CliConfigFamilyDef, + type CliConfigKeyDef, +} from "./cli-config-key-annotations.ts"; +import type { CliConfigFlagDeclaration, CliConfigNoFlags } from "./cli-config-flags.ts"; +import { + cliEnvName, + commaListCodec, + boolCodec, + uintCodec, + literalCodec, + makeCliConfigKey, + portCodec, + stringCodec, + type CliConfigKey, + type CliConfigKeyContext, + type CliEnvName, +} from "./cli-config-key.ts"; + +/** A key with its value types erased, as the registry stores every key. */ +export type AnyCliConfigKey = CliConfigKey; + +const flagDeclarations: Readonly> = CLI_CONFIG_FLAGS; + +const contextDefaults: Readonly unknown>> = + CLI_CONFIG_CONTEXT_DEFAULTS; + +/** `SUPABASE_` + UPPER_SNAKE(path): the env name every key derives unless it is annotated. */ +export const deriveCliConfigEnvName = (path: string): string => + `SUPABASE_${path + .split(".") + .map((segment) => segment.toUpperCase()) + .join("_")}`; + +interface SchemaLeaf { + readonly segments: ReadonlyArray; + readonly node: SchemaAST.AST; + readonly optional: boolean; +} + +const unwrap = (ast: SchemaAST.AST): SchemaAST.AST => + ast._tag === "Suspend" ? unwrap(ast.thunk()) : ast; + +const collectSchemaLeaves = ( + ast: SchemaAST.AST, + segments: ReadonlyArray, + out: Array, +): void => { + const node = unwrap(ast); + if (node._tag === "Objects") { + for (const property of node.propertySignatures) { + if (typeof property.name === "string") { + collectSchemaLeaves(property.type, [...segments, property.name], out); + } + } + return; + } + out.push({ segments, node, optional: node.context?.isOptional === true }); +}; + +const codecForLeaf = (segments: ReadonlyArray, node: SchemaAST.AST) => { + switch (node._tag) { + case "Boolean": + return boolCodec; + case "Number": { + const last = segments[segments.length - 1] ?? ""; + return last === "port" || last.endsWith("_port") ? portCodec : uintCodec; + } + case "String": + return stringCodec; + case "Arrays": { + const [item] = node.rest; + return node.elements.length === 0 && + node.rest.length === 1 && + item !== undefined && + unwrap(item)._tag === "String" + ? commaListCodec + : undefined; + } + case "Union": { + const values: Array = []; + for (const member of node.types) { + if (member._tag !== "Literal" || typeof member.literal !== "string") return undefined; + values.push(member.literal); + } + return literalCodec(values); + } + default: + return undefined; + } +}; + +const buildKey = (def: CliConfigKeyDef): AnyCliConfigKey => { + const segments = def.segments ?? def.path.split("."); + const env = def.noEnv === true ? [] : (def.env ?? [deriveCliConfigEnvName(def.path)]); + const section = envRequiresSectionFor(segments); + const normalize = CLI_CONFIG_NORMALIZERS[def.path]; + const optional = def.optional === true && def.defaultFrom === undefined; + const flags = flagDeclarations[def.path]; + const fixedDefault = def.defaultFrom ?? (() => def.default); + const defaultValue = + normalize === undefined + ? fixedDefault + : (ctx: CliConfigKeyContext) => normalize(fixedDefault(ctx), ctx); + return makeCliConfigKey( + { + path: def.path, + segments, + env, + codec: CLI_CONFIG_CODEC_OVERRIDES[def.path] ?? def.codec, + ...(def.secret === undefined ? {} : { secret: def.secret }), + ...(normalize === undefined ? {} : { normalize }), + ...(section === undefined ? {} : { envRequiresSection: section }), + ...(def.envScope === undefined ? {} : { envScope: def.envScope }), + ...(def.document === undefined ? {} : { document: def.document }), + ...(def.defaultFrom === undefined && def.materializeDefault === undefined + ? {} + : { materializeDefault: true as const }), + ...(flags === undefined ? {} : { flags }), + }, + optional + ? { + defaultValue: () => Option.none(), + wrap: (value) => Option.some(value), + toDocument: (value) => (Option.isOption(value) ? Option.getOrUndefined(value) : value), + } + : { + defaultValue, + wrap: (value) => value, + toDocument: (value) => value, + }, + ); +}; + +/** Key definitions for every leaf of a config schema; a leaf with no codec must be excluded explicitly. */ +export const cliConfigSchemaKeyDefs = (root: SchemaAST.Objects): ReadonlyArray => { + const leaves: Array = []; + for (const property of root.propertySignatures) { + if (typeof property.name === "string" && property.name !== "remotes") { + collectSchemaLeaves(property.type, [property.name], leaves); + } + } + return leaves.flatMap((leaf): ReadonlyArray => { + const path = leaf.segments.join("."); + const codec = CLI_CONFIG_CODEC_OVERRIDES[path] ?? codecForLeaf(leaf.segments, leaf.node); + if (codec === undefined) { + if (path in CLI_CONFIG_SCHEMA_EXCLUDED) return []; + throw new Error( + `The config schema leaf "${path}" has no key codec; add it to CLI_CONFIG_SCHEMA_EXCLUDED or CLI_CONFIG_CODEC_OVERRIDES`, + ); + } + const defaultFrom = contextDefaults[path]; + const configured = leaf.node.annotations?.["default"]; + return [ + { + path, + codec, + ...(leaf.optional && defaultFrom === undefined + ? { optional: true as const } + : defaultFrom === undefined + ? { default: configured } + : { defaultFrom }), + ...(leaf.node.annotations?.["x-secret"] === true ? { secret: true as const } : {}), + ...(CLI_CONFIG_ENV_EXCLUDED[path] === undefined ? {} : { noEnv: true as const }), + }, + ]; + }); +}; + +const registryKeys: ReadonlyArray = [ + ...cliConfigSchemaKeyDefs(CliConfigSchema.ast), + ...CLI_CONFIG_DOCUMENT_KEYS, + ...CLI_CONFIG_LINKED_KEYS, +].map(buildKey); + +const keysByPath: ReadonlyMap = new Map( + registryKeys.map((key) => [key.path, key]), +); + +const keysByEnvName: ReadonlyMap = new Map( + registryKeys.flatMap((key) => key.env.map((name): [string, AnyCliConfigKey] => [name, key])), +); + +export const cliConfigRegistry = { + keys: registryKeys, + keyAt: (path: string): AnyCliConfigKey | undefined => keysByPath.get(path), + keyForEnvName: (name: string): AnyCliConfigKey | undefined => keysByEnvName.get(name), + families: CLI_CONFIG_FAMILIES, +} as const; + +const familyKeys = new Map(); + +const familyEnvName = (family: CliConfigFamilyDef, name: string, field: string): string => + `${deriveCliConfigEnvName(`${family.prefix}.${name}`)}_${field.toUpperCase()}`; + +/** The env names that can override the fields of one named entry in a family. */ +export const cliConfigFamilyEnvNames = ( + family: CliConfigFamilyDef, + name: string, +): ReadonlyArray => family.fields.map((field) => familyEnvName(family, name, field.name)); + +/** Paths the schema does not model, so only the raw document and the environment can supply them. */ +export const cliConfigDocumentOnlyPaths: ReadonlySet = new Set( + CLI_CONFIG_DOCUMENT_KEYS.map((def) => def.path), +); + +/** The key for one field of a named entry in an arbitrarily-keyed table, e.g. `auth.external.github.secret`. */ +export const cliConfigFamilyKey = ( + family: CliConfigFamilyDef, + name: string, + field: string, +): AnyCliConfigKey | undefined => { + const spec = family.fields.find((candidate) => candidate.name === field); + if (spec === undefined) return undefined; + const segments = [...family.prefix.split("."), name, field]; + const path = segments.join("."); + const registered = keysByPath.get(path); + if (registered !== undefined) return registered; + const cacheKey = JSON.stringify(segments); + const cached = familyKeys.get(cacheKey); + if (cached !== undefined) return cached; + const key = buildKey({ + path, + segments, + codec: spec.codec, + env: [familyEnvName(family, name, field)], + ...(spec.optional === undefined ? { default: spec.default } : { optional: true }), + ...(spec.secret === undefined ? {} : { secret: spec.secret }), + }); + familyKeys.set(cacheKey, key); + return key; +}; + +type CamelCase = S extends `${infer Head}_${infer Tail}` + ? `${Head}${Capitalize>}` + : S; + +type ChildPath = Parent extends "" + ? Segment + : `${Parent}.${Segment}`; + +type FlagDeclarationAt = Path extends keyof typeof CLI_CONFIG_FLAGS + ? (typeof CLI_CONFIG_FLAGS)[Path] + : CliConfigNoFlags; + +type ContextDefaultPath = keyof typeof CLI_CONFIG_CONTEXT_DEFAULTS; + +type LeafKey = Optional extends true + ? Path extends ContextDefaultPath + ? CliConfigKey, NonNullable, FlagDeclarationAt> + : CliConfigKey>, NonNullable, FlagDeclarationAt> + : CliConfigKey>; + +type TreeNode = + NonNullable extends ReadonlyArray + ? NonNullable extends ReadonlyArray + ? LeafKey + : never + : NonNullable extends object + ? string extends keyof NonNullable + ? never + : CliConfigKeyTree, Path> + : LeafKey; + +/** The registry's nested accessor type, derived from the `CliConfig` document type. */ +type CliConfigKeyTree = { + readonly [ + K in keyof T & string as [TreeNode>] extends [never] + ? never + : CamelCase + ]-?: TreeNode ? true : false, ChildPath>; +}; + +interface DocumentOnlyKeyTree { + readonly db: { + readonly password: CliConfigKey; + readonly rootKey: CliConfigKey; + }; + readonly auth: { + readonly externalUrl: CliConfigKey, string>; + readonly passkey: { readonly enabled: CliConfigKey }; + readonly webauthn: { + readonly rpId: CliConfigKey; + readonly rpDisplayName: CliConfigKey; + readonly rpOrigins: CliConfigKey>; + }; + }; + readonly linkedDb: { + readonly password: CliConfigKey< + Option.Option, + string, + FlagDeclarationAt<"linkedDb.password"> + >; + }; +} + +type CliConfigKeysTree = CliConfigKeyTree & DocumentOnlyKeyTree; + +const camelCase = (segment: string): string => + segment.replace(/_([a-z0-9])/g, (_match, letter: string) => letter.toUpperCase()); + +const buildTree = (keys: ReadonlyArray): Record => { + const root: Record = {}; + for (const key of keys) { + const segments = key.path.split(".").map(camelCase); + const leafName = segments[segments.length - 1] ?? key.path; + let node = root; + for (const segment of segments.slice(0, -1)) { + const existing = node[segment]; + const next: Record = + typeof existing === "object" && existing !== null ? { ...existing } : {}; + node[segment] = next; + node = next; + } + node[leafName] = key; + } + return root; +}; + +const isKeyTree = (value: unknown): value is CliConfigKeysTree => + typeof value === "object" && value !== null && Object.keys(value).length > 0; + +const builtTree = buildTree(registryKeys); + +if (!isKeyTree(builtTree)) { + throw new Error("The CLI config key registry produced no keys"); +} + +/** Every config key, addressed by camelCased document path, e.g. `CliConfigKeys.db.seed.enabled`. */ +export const CliConfigKeys: CliConfigKeysTree = builtTree; + +/** The env name that overrides a `[remotes.]` block's `project_id` for remote selection. */ +export const cliRemoteProjectIdEnvName = (remote: string): string => + `SUPABASE_REMOTES_${remote.toUpperCase()}_PROJECT_ID`; + +/** + * Whether `name` is an env override the registry owns, including the names derived for entries of + * a dynamic family such as `SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET`. + */ +export const isCliConfigEnvName = (name: string): boolean => + keysByEnvName.has(name) || + CLI_CONFIG_FAMILIES.some((family) => { + const prefix = `${deriveCliConfigEnvName(family.prefix)}_`; + return ( + name.startsWith(prefix) && + family.fields.some((field) => name.endsWith(`_${field.name.toUpperCase()}`)) + ); + }); + +/** The entry names of a family that the registry itself declares, e.g. the built-in auth hooks. */ +export const staticCliConfigFamilyNames = (family: CliConfigFamilyDef): ReadonlyArray => { + const prefix = `${family.prefix}.`; + return [ + ...new Set( + registryKeys.flatMap((key) => + key.path.startsWith(prefix) ? [key.path.slice(prefix.length).split(".")[0] ?? ""] : [], + ), + ), + ]; +}; + +/** Env-name-only reads: shell-only, so the name lives in the registry but no tier list applies. */ +export const CliEnvNames = { + projectId: cliEnvName({ + name: deriveCliConfigEnvName("project_id"), + codec: stringCodec, + configKeyPath: "project_id", + }), + authServiceRoleKey: cliEnvName({ + name: deriveCliConfigEnvName("auth.service_role_key"), + codec: stringCodec, + configKeyPath: "auth.service_role_key", + }), +} as const satisfies Record>; diff --git a/apps/cli/src/config/cli-config-keys.unit.test.ts b/apps/cli/src/config/cli-config-keys.unit.test.ts new file mode 100644 index 0000000000..0b88fcbe19 --- /dev/null +++ b/apps/cli/src/config/cli-config-keys.unit.test.ts @@ -0,0 +1,264 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { BunServices } from "@effect/platform-bun"; +import { CliConfigSchema } from "@supabase/config"; +import { DEFAULT_POSTGRES_ROOT_KEY } from "@supabase/stack/defaults"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Path, Schema, type SchemaAST } from "effect"; + +import { getDocumentValue } from "./cli-config-document.ts"; +import { + CLI_CONFIG_FAMILIES, + CLI_CONFIG_SCHEMA_EXCLUDED, + CLI_CONFIG_SECTION_ENV_EXEMPT, + CLI_NON_CONFIG_ENV_NAMES, +} from "./cli-config-key-annotations.ts"; +import { + CliConfigKeys, + CliEnvNames, + cliConfigDocumentOnlyPaths, + cliConfigFamilyKey, + cliConfigRegistry, + cliConfigSchemaKeyDefs, + deriveCliConfigEnvName, +} from "./cli-config-keys.ts"; + +const srcDir = fileURLToPath(new URL("..", import.meta.url)); + +const registryFiles = new Set([ + "config/cli-config-key-annotations.ts", + "config/cli-config-key.ts", + "config/cli-config-keys.ts", +]); + +const productionSources = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts") && !file.endsWith(".test.ts")) + .filter((file) => !file.includes("__fixtures__") && !file.startsWith("shared/compute/stacks")) + .filter((file) => !registryFiles.has(file)) + .map((file) => ({ file, text: readFileSync(join(srcDir, file), "utf8") })); + +const referencedEnvNames = new Set( + productionSources.flatMap(({ text }) => + [...text.matchAll(/\bSUPABASE_[A-Z0-9_]+\b/g)].map((match) => match[0]), + ), +); + +const registryEnvNames = new Set(cliConfigRegistry.keys.flatMap((key) => key.env)); + +const camelCase = (segment: string) => + segment.replace(/_([a-z0-9])/g, (_match, letter: string) => letter.toUpperCase()); + +const treeLookup = (path: string): unknown => + path.split(".").reduce((node, segment) => { + if (typeof node !== "object" || node === null) return undefined; + return Reflect.get(node, camelCase(segment)); + }, CliConfigKeys); + +const unwrapAst = (ast: SchemaAST.AST): SchemaAST.AST => + ast._tag === "Suspend" ? unwrapAst(ast.thunk()) : ast; + +const optionalSectionPaths = ( + ast: SchemaAST.AST, + segments: ReadonlyArray = [], + optional = false, +): ReadonlyArray => { + const node = unwrapAst(ast); + if (node._tag !== "Objects") return []; + return [ + ...(optional && segments.length > 0 ? [segments.join(".")] : []), + ...node.propertySignatures.flatMap((property) => + typeof property.name === "string" + ? optionalSectionPaths( + property.type, + [...segments, property.name], + property.type.context?.isOptional === true, + ) + : [], + ), + ]; +}; + +describe("config key registry", () => { + it("gives every env name to exactly one key", () => { + const names = cliConfigRegistry.keys.flatMap((key) => key.env); + + expect(names.length).toBe(registryEnvNames.size); + }); + + it("derives env names from the document path", () => { + expect(deriveCliConfigEnvName("auth.mfa.web_authn.enroll_enabled")).toBe( + "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", + ); + expect(CliConfigKeys.db.seed.enabled.env).toEqual(["SUPABASE_DB_SEED_ENABLED"]); + expect(CliConfigKeys.db.seed.sqlPaths.path).toBe("db.seed.sql_paths"); + }); + + it("never derives a name the CLI reads for something else", () => { + const collisions = [...registryEnvNames].filter((name) => name in CLI_NON_CONFIG_ENV_NAMES); + + expect(collisions).toEqual([]); + }); + + it("accounts for every SUPABASE_* name the CLI source mentions outside the registry files", () => { + expect(referencedEnvNames.size).toBeGreaterThan(25); + const unaccounted = [...referencedEnvNames] + .filter((name) => !name.endsWith("_")) + .filter((name) => !registryEnvNames.has(name) && !(name in CLI_NON_CONFIG_ENV_NAMES)); + + expect(unaccounted).toEqual([]); + }); + + it("produces an env override for every key the db reader honoured before the registry", () => { + const dottedKeys: ReadonlyArray = JSON.parse( + readFileSync(join(srcDir, "config/testdata/env-overridable-keys.json"), "utf8"), + ); + + const missing = dottedKeys.filter((dotted) => { + const key = cliConfigRegistry.keyAt(dotted); + return key === undefined || !key.env.includes(deriveCliConfigEnvName(dotted)); + }); + + expect(dottedKeys.length).toBeGreaterThan(150); + expect(missing).toEqual([]); + }); + + it("follows the legacy prefixes for hooks, sms providers and the dynamic tables", () => { + expect(CliConfigKeys.auth.hook.sendSms.uri.env).toEqual(["SUPABASE_AUTH_HOOK_SEND_SMS_URI"]); + expect(CliConfigKeys.auth.sms.twilioVerify.accountSid.env).toEqual([ + "SUPABASE_AUTH_SMS_TWILIO_VERIFY_ACCOUNT_SID", + ]); + const [external, template, notification] = CLI_CONFIG_FAMILIES; + expect(external && cliConfigFamilyKey(external, "custom_oidc", "secret")?.env).toEqual([ + "SUPABASE_AUTH_EXTERNAL_CUSTOM_OIDC_SECRET", + ]); + expect(template && cliConfigFamilyKey(template, "invite", "content_path")?.env).toEqual([ + "SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", + ]); + expect( + notification && cliConfigFamilyKey(notification, "email_changed", "enabled")?.env, + ).toEqual(["SUPABASE_AUTH_EMAIL_NOTIFICATION_EMAIL_CHANGED_ENABLED"]); + }); + + it("keeps a dotted family name as one segment in the key path and its section gate", () => { + const external = CLI_CONFIG_FAMILIES.find((family) => family.id === "authExternal"); + + const key = external && cliConfigFamilyKey(external, "a.b", "secret"); + + expect(key?.segments).toEqual(["auth", "external", "a.b", "secret"]); + expect(key?.envRequiresSection).toEqual(["auth", "external", "a.b"]); + }); + + it("keeps document-only keys out of the environment and the linked password out of the document", () => { + expect(CliConfigKeys.db.password.env).toEqual([]); + expect(CliConfigKeys.db.rootKey.secret).toBe(true); + expect(CliConfigKeys.linkedDb.password).toMatchObject({ + env: ["SUPABASE_DB_PASSWORD"], + document: false, + envScope: "linkedTarget", + }); + expect( + cliConfigRegistry.keys + .filter((key) => key.envScope === "linkedTarget") + .map((key) => key.path), + ).toEqual(["linkedDb.password"]); + }); + + it("marks schema secrets and gates section-scoped env", () => { + expect(CliConfigKeys.auth.jwtSecret.secret).toBe(true); + expect(CliConfigKeys.auth.captcha.secret.envRequiresSection).toEqual(["auth", "captcha"]); + expect(CliConfigKeys.experimental.webhooks.enabled.envRequiresSection).toEqual([ + "experimental", + "webhooks", + ]); + expect(CliConfigKeys.storage.imageTransformation.enabled.envRequiresSection).toEqual([ + "storage", + "image_transformation", + ]); + expect(CliConfigKeys.db.seed.enabled.envRequiresSection).toBeUndefined(); + }); + + it("gates the env of every optional schema section unless the section is exempt", () => { + const sections = optionalSectionPaths(CliConfigSchema.ast).filter((section) => + cliConfigRegistry.keys.some((key) => key.path.startsWith(`${section}.`)), + ); + + const ungated = sections + .filter((section) => !(section in CLI_CONFIG_SECTION_ENV_EXEMPT)) + .flatMap((section) => + cliConfigRegistry.keys + .filter((key) => key.env.length > 0 && key.path.startsWith(`${section}.`)) + .filter((key) => key.envRequiresSection?.join(".") !== section) + .map((key) => key.path), + ); + + expect(sections).toContain("db.ssl_enforcement"); + expect(ungated).toEqual([]); + expect(Object.keys(CLI_CONFIG_SECTION_ENV_EXEMPT).filter((s) => !sections.includes(s))).toEqual( + [], + ); + }); + + it("exposes every registry key through the typed tree", () => { + const misaligned = cliConfigRegistry.keys.filter((key) => treeLookup(key.path) !== key); + + expect(misaligned.map((key) => key.path)).toEqual([]); + }); + + it.effect("defaults every key to what the schema decodes from an empty document", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const decoded = Schema.decodeUnknownSync(CliConfigSchema)({}); + const context = { + workdir: "/work/proj", + projectRef: Option.none(), + path, + configAt: () => undefined, + }; + + const mismatched = cliConfigRegistry.keys.flatMap((key) => { + const raw = getDocumentValue(decoded, key.segments); + if ( + key.document === false || + key.materializeDefault === true || + cliConfigDocumentOnlyPaths.has(key.path) + ) { + return []; + } + const expected = + raw === undefined + ? Option.none() + : key.normalize === undefined + ? raw + : key.normalize(raw, context); + const actual = key.defaultValue(context); + const matches = + raw === undefined + ? Option.isOption(actual) && Option.isNone(actual) + : JSON.stringify(actual) === JSON.stringify(expected); + return matches ? [] : [{ path: key.path, expected, actual }]; + }); + + expect(mismatched).toEqual([]); + expect(CliConfigKeys.db.rootKey.defaultValue(context)).toBe(DEFAULT_POSTGRES_ROOT_KEY); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it("keeps every schema leaf in the registry unless it is explicitly excluded", () => { + expect(Object.keys(CLI_CONFIG_SCHEMA_EXCLUDED)).toEqual(["experimental.inspect.rules"]); + expect(cliConfigRegistry.keyAt("experimental.inspect.rules")).toBeUndefined(); + expect(cliConfigRegistry.keyAt("experimental.webhooks.enabled")).toBeDefined(); + }); + + it("refuses a schema leaf it has no codec for", () => { + const withDate = Schema.Struct({ nested: Schema.Struct({ when: Schema.Date }) }); + + expect(() => cliConfigSchemaKeyDefs(withDate.ast)).toThrow(/nested\.when/); + }); + + it("reads shell-only names for the project id and service role key", () => { + expect(CliEnvNames.projectId.name).toBe(CliConfigKeys.projectId.env[0]); + expect(CliEnvNames.authServiceRoleKey.name).toBe(CliConfigKeys.auth.serviceRoleKey.env[0]); + }); +}); diff --git a/apps/cli/src/config/cli-config-remote-precedence.integration.test.ts b/apps/cli/src/config/cli-config-remote-precedence.integration.test.ts new file mode 100644 index 0000000000..ccc62fd7c3 --- /dev/null +++ b/apps/cli/src/config/cli-config-remote-precedence.integration.test.ts @@ -0,0 +1,132 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; + +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { createStackConfigProject } from "../../tests/helpers/stack-config.ts"; +import { CLI_CONFIG_FAMILIES } from "./cli-config-key-annotations.ts"; +import { cliConfigFamilyKey, cliConfigRegistry, type AnyCliConfigKey } from "./cli-config-keys.ts"; +import { CliConfigValues } from "./cli-config-values.service.ts"; + +const LINKED = "abcdefghijklmnopqrst"; + +const registered = (path: string) => { + const key = cliConfigRegistry.keyAt(path); + if (key === undefined) throw new Error(`${path} is not in the registry`); + return key; +}; + +const familyKey = (id: string, name: string, field: string) => { + const family = CLI_CONFIG_FAMILIES.find((candidate) => candidate.id === id); + const key = family === undefined ? undefined : cliConfigFamilyKey(family, name, field); + if (key === undefined) throw new Error(`no ${id} family key for ${field}`); + return key; +}; + +interface Scenario { + readonly name: string; + readonly key: AnyCliConfigKey; + readonly optional?: true; + readonly base: string; + readonly remote: string; + readonly remoteValue: unknown; + readonly envValue: string; + readonly decodedEnvValue: unknown; +} + +const scenarios: ReadonlyArray = [ + { + name: "a top-level numeric section value", + key: registered("api.max_rows"), + base: "[api]\nmax_rows = 10\n", + remote: "[remotes.staging.api]\nmax_rows = 20\n", + remoteValue: 20, + envValue: "30", + decodedEnvValue: 30, + }, + { + name: "a top-level auth value", + key: registered("auth.site_url"), + base: '[auth]\nsite_url = "https://base.example"\n', + remote: '[remotes.staging.auth]\nsite_url = "https://remote.example"\n', + remoteValue: "https://remote.example", + envValue: "https://env.example", + decodedEnvValue: "https://env.example", + }, + { + name: "an external provider field", + key: familyKey("authExternal", "github", "client_id"), + base: '[auth.external.github]\nenabled = true\nclient_id = "base-id"\nsecret = "env(GITHUB_SECRET)"\n', + remote: + '[remotes.staging.auth.external.github]\nenabled = true\nclient_id = "remote-id"\nsecret = "env(GITHUB_SECRET)"\n', + remoteValue: "remote-id", + envValue: "env-id", + decodedEnvValue: "env-id", + }, + { + name: "an email template field", + key: familyKey("authEmailTemplate", "invite", "subject"), + optional: true, + base: '[auth.email.template.invite]\nsubject = "Base"\n', + remote: '[remotes.staging.auth.email.template.invite]\nsubject = "Remote"\n', + remoteValue: "Remote", + envValue: "Env", + decodedEnvValue: "Env", + }, + { + name: "an email notification field", + key: familyKey("authEmailNotification", "password_changed", "subject"), + optional: true, + base: '[auth.email.notification.password_changed]\nenabled = true\nsubject = "Base"\n', + remote: + '[remotes.staging.auth.email.notification.password_changed]\nenabled = true\nsubject = "Remote"\n', + remoteValue: "Remote", + envValue: "Env", + decodedEnvValue: "Env", + }, + { + name: "a hook field", + key: familyKey("authHook", "send_email", "uri"), + base: '[auth.hook.send_email]\nenabled = true\nuri = "https://base.example/hook"\n', + remote: + '[remotes.staging.auth.hook.send_email]\nenabled = true\nuri = "https://remote.example/hook"\n', + remoteValue: "https://remote.example/hook", + envValue: "https://env.example/hook", + decodedEnvValue: "https://env.example/hook", + }, +]; + +const expected = (scenario: Scenario, value: unknown) => + scenario.optional === true ? Option.some(value) : value; + +const readKey = (scenario: Scenario, shell: Readonly>) => + Effect.gen(function* () { + const root = yield* createStackConfigProject( + `project_id = "base"\n${scenario.base}\n[remotes.staging]\nproject_id = "${LINKED}"\n${scenario.remote}`, + { prefix: "supabase-cli-remote-precedence-", supabaseEnv: "GITHUB_SECRET=g\n" }, + ); + const layer = configValuesLayer({ env: shell }); + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + return yield* resolvedConfig.get(scenario.key); + }).pipe(Effect.provide(BunServices.layer), Effect.scoped); + +describe("CliConfigValues env versus a matched remote", () => { + for (const scenario of scenarios) { + const envName = scenario.key.env[0]; + if (envName === undefined) throw new Error(`${scenario.name} has no env name`); + + it.live(`takes the matched remote for ${scenario.name} until its env override is set`, () => + Effect.gen(function* () { + const withoutEnv = yield* readKey(scenario, {}); + expect(withoutEnv.value).toEqual(expected(scenario, scenario.remoteValue)); + expect(withoutEnv.origin).toMatchObject({ tier: "config", remote: "staging" }); + + const withEnv = yield* readKey(scenario, { [envName]: scenario.envValue }); + expect(withEnv.value).toEqual(expected(scenario, scenario.decodedEnvValue)); + expect(withEnv.origin).toMatchObject({ tier: "shell", envName }); + }), + ); + } +}); diff --git a/apps/cli/src/config/cli-config-remote.ts b/apps/cli/src/config/cli-config-remote.ts new file mode 100644 index 0000000000..062c26d122 --- /dev/null +++ b/apps/cli/src/config/cli-config-remote.ts @@ -0,0 +1,70 @@ +import { Option } from "effect"; + +import { PROJECT_REF_PATTERN } from "./project-ref.service.ts"; +import { isDocumentRecord } from "./cli-config-document.ts"; +import { cliRemoteProjectIdEnvName } from "./cli-config-keys.ts"; +import { expandCliConfigEnvReference } from "./cli-config-key.ts"; + +type EnvLookup = (name: string) => string | undefined; + +const remoteBlocks = (remotes: Readonly>) => + Object.entries(remotes).map(([name, block]) => ({ + name, + block: isDocumentRecord(block) ? block : undefined, + })); + +const literalProjectId = (block: Readonly> | undefined) => { + const literal = block?.["project_id"]; + return typeof literal === "string" ? literal : undefined; +}; + +/** A remote's `project_id` for matching: the env override when non-empty, else the raw TOML literal. */ +const matchProjectId = ( + name: string, + block: Readonly> | undefined, + lookup: EnvLookup, +) => lookup(cliRemoteProjectIdEnvName(name)) ?? literalProjectId(block); + +/** The first `[remotes.]` whose effective `project_id` equals the target ref. */ +export const selectCliConfigRemote = ( + remotes: Readonly>, + projectRef: Option.Option, + lookup: EnvLookup, +): string | undefined => { + if (Option.isNone(projectRef)) return undefined; + return remoteBlocks(remotes).find( + ({ name, block }) => + block !== undefined && matchProjectId(name, block, lookup) === projectRef.value, + )?.name; +}; + +/** + * The load failure for a duplicate or malformed remote `project_id`, if any. Runs on every load, + * regardless of which remote is selected. + */ +export const cliConfigRemoteFailure = ( + remotes: Readonly>, + lookup: EnvLookup, +): string | undefined => { + const seen = new Map(); + for (const { name, block } of remoteBlocks(remotes)) { + const projectId = matchProjectId(name, block, lookup); + if (projectId === undefined) continue; + const prior = seen.get(projectId); + if (prior !== undefined) { + return `duplicate project_id for [remotes.${name}] and [remotes.${prior}]`; + } + seen.set(projectId, name); + } + for (const { name, block } of remoteBlocks(remotes)) { + const override = lookup(cliRemoteProjectIdEnvName(name)); + const literal = literalProjectId(block); + const projectId = + override ?? + (literal === undefined ? undefined : expandCliConfigEnvReference(literal, lookup)); + if (projectId === undefined || !PROJECT_REF_PATTERN.test(projectId)) { + return `Invalid config for remotes.${name}.project_id. Must be like: abcdefghijklmnopqrst`; + } + } + return undefined; +}; diff --git a/apps/cli/src/config/cli-config-subtree.ts b/apps/cli/src/config/cli-config-subtree.ts new file mode 100644 index 0000000000..e64e9bc157 --- /dev/null +++ b/apps/cli/src/config/cli-config-subtree.ts @@ -0,0 +1,22 @@ +import { resolveCliConfigSubtree } from "@supabase/config/internal"; +import { Effect } from "effect"; + +import { envReferenceNames } from "./cli-config-document.ts"; +import type { ResolvedCliConfig } from "./cli-config-values.service.ts"; + +/** Resolves `env()` references against `values` and wraps secret leaves in `Redacted` for a subtree the registry does not model. */ +export const resolveCliSubtree = ( + tree: T, + values: Readonly>, + path: string, +) => resolveCliConfigSubtree(tree, { values }, path, { cliCompat: true }); + +/** {@link resolveCliSubtree} with the values the resolved config's shell and project `.env*` supply. */ +export const resolveConfigSubtree = Effect.fn("CliConfigSubtree.resolve")(function* ( + resolvedConfig: ResolvedCliConfig, + tree: T, + path: string, +) { + const values = yield* resolvedConfig.envValues(envReferenceNames(tree)); + return yield* resolveCliSubtree(tree, values, path); +}); diff --git a/apps/cli/src/config/cli-config-validation.ts b/apps/cli/src/config/cli-config-validation.ts new file mode 100644 index 0000000000..8df3b7c500 --- /dev/null +++ b/apps/cli/src/config/cli-config-validation.ts @@ -0,0 +1,15 @@ +import { + decodeCliConfigDocumentForValidationEffect, + type DecodeCliConfigDocumentForValidationEffectOptions, +} from "@supabase/config/internal"; + +export type CliConfigValidationOptions = Omit< + DecodeCliConfigDocumentForValidationEffectOptions, + "cliCompat" +>; + +/** Decodes a pending config document with the CLI's own decode semantics. */ +export const decodeCliConfigDocumentForValidation = ( + document: Record, + options: CliConfigValidationOptions, +) => decodeCliConfigDocumentForValidationEffect(document, { ...options, cliCompat: true }); diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts new file mode 100644 index 0000000000..9bab5c64ef --- /dev/null +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -0,0 +1,1838 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import type { CliConfig } from "@supabase/config"; +import { loadCliConfig } from "@supabase/config/internal"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { vi } from "vitest"; + +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { definedEnv } from "../../tests/helpers/config-env-pins.ts"; +import { + cliConfigValuesTestLayer, + configValuesLayer, + flagInput, +} from "../../tests/helpers/config-values-layer.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { createStackConfigProject } from "../../tests/helpers/stack-config.ts"; +import { loadStackConfig } from "../command-internal/stack-config.ts"; +import { CliEnvNameSource } from "../shared/config/cli-config-provider.layer.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { + CliConfigFlagInputs, + makeCliConfigFlagInputs, + type CliConfigFlagAssignment, +} from "./cli-config-flags.ts"; +import { CLI_CONFIG_FAMILIES, type CliConfigFamilyId } from "./cli-config-key-annotations.ts"; +import { + CliConfigKeys, + cliConfigDocumentOnlyPaths, + cliConfigFamilyKey, + cliConfigRegistry, +} from "./cli-config-keys.ts"; +import { cliConfigValuesLayer } from "./cli-config-values.layer.ts"; +import { CliConfigValues } from "./cli-config-values.service.ts"; + +const LINKED = "abcdefghijklmnopqrst"; +const OTHER = "tsrqponmlkjihgfedcba"; +const CIPHERTEXT = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; +const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + +const familyKey = (id: CliConfigFamilyId, name: string, field: string) => { + const family = CLI_CONFIG_FAMILIES.find((candidate) => candidate.id === id); + const key = family === undefined ? undefined : cliConfigFamilyKey(family, name, field); + if (key === undefined) throw new Error(`no ${id} family key for ${field}`); + return key; +}; + +const withShell = ( + shell: Readonly>, + body: Effect.Effect, +) => withConfigEnv(definedEnv(shell), body); + +const link = (root: string, ref: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "supabase", ".temp", "project-ref"), ref); + }); + +const project = (config: string, options: { readonly supabaseEnv?: string } = {}) => + createStackConfigProject(config, { prefix: "supabase-cli-config-values-", ...options }); + +describe("CliConfigValues credential scoping", () => { + const scenarios: ReadonlyArray<{ + readonly name: string; + readonly linkedTo: string | undefined; + readonly target: Option.Option; + readonly withheld: boolean; + }> = [ + { + name: "a target that matches the linked project", + linkedTo: LINKED, + target: Option.some(LINKED), + withheld: false, + }, + { + name: "a target other than the linked project", + linkedTo: LINKED, + target: Option.some(OTHER), + withheld: true, + }, + { + name: "a target in an unlinked workdir", + linkedTo: undefined, + target: Option.some(OTHER), + withheld: false, + }, + { name: "no target", linkedTo: LINKED, target: Option.none(), withheld: false }, + ]; + + for (const scenario of scenarios) { + it.live( + `${scenario.withheld ? "withholds" : "offers"} SUPABASE_DB_PASSWORD for ${scenario.name}`, + () => + Effect.gen(function* () { + const root = yield* project('project_id = "scoped"\n', { + supabaseEnv: "SUPABASE_DB_PASSWORD=from-file\n", + }); + if (scenario.linkedTo !== undefined) yield* link(root, scenario.linkedTo); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: scenario.target }), + ).pipe(Effect.provide(layer)); + const password = yield* resolvedConfig.get(CliConfigKeys.linkedDb.password); + + if (scenario.withheld) { + expect(password.value).toEqual(Option.none()); + expect(resolvedConfig.withheldEnv).toEqual([ + { + path: "linkedDb.password", + envName: "SUPABASE_DB_PASSWORD", + tier: "shell", + targetRef: Option.getOrElse(scenario.target, () => ""), + linkedRef: LINKED, + }, + ]); + } else { + expect(password.value).toEqual(Option.some("from-shell")); + expect(password.origin).toMatchObject({ tier: "shell" }); + expect(resolvedConfig.withheldEnv).toEqual([]); + } + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_PASSWORD: "from-shell" }, effect), + Effect.scoped, + ), + ); + } + + it.live("withholds a project .env password from every view when the shell does not set one", () => + Effect.gen(function* () { + const root = yield* project('project_id = "scoped"\n', { + supabaseEnv: "SUPABASE_DB_PASSWORD=from-file\nUNSCOPED_VAR=kept\n", + }); + yield* link(root, LINKED); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(OTHER) }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.withheldEnv).toMatchObject([ + { envName: "SUPABASE_DB_PASSWORD", tier: "projectEnv" }, + ]); + expect(Object.hasOwn(resolvedConfig.projectEnvValues, "SUPABASE_DB_PASSWORD")).toBe(false); + expect(resolvedConfig.projectEnvValues["UNSCOPED_VAR"]).toBe("kept"); + expect(yield* resolvedConfig.envValues(["SUPABASE_DB_PASSWORD"])).toEqual({}); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_PASSWORD: undefined }, effect), + Effect.scoped, + ), + ); + + it.live("keeps an explicit --password on a foreign project", () => + Effect.gen(function* () { + const root = yield* project('project_id = "scoped"\n'); + yield* link(root, LINKED); + const layer = configValuesLayer({ + flags: [flagInput("linkedDb.password", "password", "explicit")], + }); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(OTHER) }), + ).pipe(Effect.provide(layer)); + const password = yield* resolvedConfig.get(CliConfigKeys.linkedDb.password); + + expect(password).toMatchObject({ value: Option.some("explicit"), origin: { tier: "flag" } }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_PASSWORD: "from-shell" }, effect), + Effect.scoped, + ), + ); + + it.live("keeps config secrets env-sourced on a foreign project", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "scoped"\n[auth.captcha]\nenabled = true\nprovider = "hcaptcha"\nsecret = "from-config"\n', + ); + yield* link(root, LINKED); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(OTHER) }), + ).pipe(Effect.provide(layer)); + const secret = yield* resolvedConfig.get(CliConfigKeys.auth.captcha.secret); + + expect(secret).toMatchObject({ + value: Option.some("from-shell"), + origin: { tier: "shell", envName: "SUPABASE_AUTH_CAPTCHA_SECRET" }, + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_AUTH_CAPTCHA_SECRET: "from-shell" }, effect), + Effect.scoped, + ), + ); +}); + +describe("CliConfigValues remotes", () => { + const remoteConfig = `project_id = "base" +[db] +port = 54399 + +[remotes.staging] +project_id = "${LINKED}" + +[remotes.staging.db] +major_version = 15 +`; + + it.live("merges the matching remote and keeps its implicit seed default in the config tier", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.appliedRemote).toEqual(Option.some("staging")); + expect((yield* resolvedConfig.get(CliConfigKeys.db.majorVersion)).origin).toMatchObject({ + tier: "config", + remote: "staging", + origin: { source: "remote" }, + }); + expect(yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).toMatchObject({ + value: false, + origin: { tier: "config", remote: "staging" }, + }); + expect((yield* resolvedConfig.get(CliConfigKeys.db.port)).origin).toMatchObject({ + tier: "config", + origin: { source: "local" }, + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("lets env and flags beat the matched remote, including its seed default", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const layer = configValuesLayer({ + flags: [flagInput("db.seed.enabled", "include-seed", true)], + }); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + + expect(yield* resolvedConfig.get(CliConfigKeys.db.majorVersion)).toMatchObject({ + value: 17, + origin: { tier: "shell" }, + }); + expect(yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).toMatchObject({ + value: true, + origin: { tier: "flag", flag: "include-seed" }, + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_MAJOR_VERSION: "17" }, effect), + Effect.scoped, + ), + ); + + it.live("warns once for each env value that beats a value the matched remote declares", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const output = mockOutput(); + const layer = configValuesLayer({ output: output.layer }); + + yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + + expect(output.messages).toEqual([ + { + type: "warn", + message: + "SUPABASE_DB_MAJOR_VERSION (shell) overrides db.major_version in [remotes.staging].", + }, + ]); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_MAJOR_VERSION: "17", SUPABASE_DB_PORT: "54400" }, effect), + Effect.scoped, + ), + ); + + it.live("logs where each non-default value came from under --debug", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const lines: Array = []; + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => Effect.sync(() => void lines.push(message)), + http: () => Effect.void, + }); + const layer = configValuesLayer({ debugLogger }); + + yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + + expect(lines).toContainEqual(expect.stringContaining("config: db.major_version from")); + expect(lines).toContainEqual(expect.stringContaining("SUPABASE_DB_PORT")); + expect(lines.some((line) => line.startsWith("config: api.port"))).toBe(false); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_PORT: "54400" }, effect), + Effect.scoped, + ), + ); + + it.live("selects the remote named by SUPABASE_REMOTES__PROJECT_ID", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(OTHER) }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.appliedRemote).toEqual(Option.some("staging")); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_REMOTES_STAGING_PROJECT_ID: OTHER }, effect), + Effect.scoped, + ), + ); + + it.live("applies no remote when the ref matches none", () => + Effect.gen(function* () { + const root = yield* project(remoteConfig); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(OTHER) }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.appliedRemote).toEqual(Option.none()); + expect((yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).origin).toEqual({ + tier: "default", + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("fails the load on duplicate or malformed remote project ids", () => + Effect.gen(function* () { + const duplicate = yield* project( + `[remotes.a]\nproject_id = "${LINKED}"\n[remotes.b]\nproject_id = "${LINKED}"\n`, + ); + const malformed = yield* project('[remotes.a]\nproject_id = "short"\n'); + const layer = configValuesLayer(); + const load = (root: string) => + CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.flip, Effect.provide(layer)); + + expect((yield* load(duplicate)).message).toBe( + "duplicate project_id for [remotes.b] and [remotes.a]", + ); + expect((yield* load(malformed)).message).toBe( + "Invalid config for remotes.a.project_id. Must be like: abcdefghijklmnopqrst", + ); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); +}); + +describe("CliConfigValues memoisation", () => { + it.live("memoises a load per target and drops the memo after a write", () => + Effect.gen(function* () { + const root = yield* project("[db]\nport = 54399\n"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layer = configValuesLayer(); + + yield* Effect.gen(function* () { + const values = yield* CliConfigValues; + const target = { workdir: root, projectRef: Option.none() }; + const first = yield* values.load(target); + const again = yield* values.load(target); + const other = yield* values.load({ ...target, projectRef: Option.some(LINKED) }); + + expect(again).toBe(first); + expect(other).not.toBe(first); + + yield* values.writeThrough( + fs.writeFileString(path.join(root, "supabase", "config.toml"), "[db]\nport = 54400\n"), + ); + const reloaded = yield* values.load(target); + + expect(reloaded).not.toBe(first); + expect((yield* reloaded.get(CliConfigKeys.db.port)).value).toBe(54400); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("defaults pg-delta on and lets config turn it off", () => + Effect.gen(function* () { + const bare = yield* project('project_id = "bare"\n'); + const disabled = yield* project( + 'project_id = "off"\n\n[experimental.pgdelta]\nenabled = false\n', + ); + const layer = configValuesLayer(); + + yield* Effect.gen(function* () { + const values = yield* CliConfigValues; + const load = (workdir: string) => values.load({ workdir, projectRef: Option.none() }); + + const byDefault = yield* (yield* load(bare)).get( + CliConfigKeys.experimental.pgdelta.enabled, + ); + const byConfig = yield* (yield* load(disabled)).get( + CliConfigKeys.experimental.pgdelta.enabled, + ); + + expect(byDefault).toMatchObject({ value: true, origin: { tier: "default" } }); + expect(byConfig).toMatchObject({ value: false, origin: { tier: "config" } }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("ignores SUPABASE_EXPERIMENTAL_PG_DELTA and honours the canonical env name", () => + Effect.gen(function* () { + const root = yield* project('project_id = "env"\n'); + const output = mockOutput(); + const layer = configValuesLayer({ output: output.layer }); + + yield* Effect.gen(function* () { + const values = yield* CliConfigValues; + const resolved = yield* values.load({ workdir: root, projectRef: Option.none() }); + + expect(yield* resolved.get(CliConfigKeys.experimental.pgdelta.enabled)).toMatchObject({ + value: false, + origin: { tier: "shell", envName: "SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED" }, + }); + }).pipe(Effect.provide(layer)); + expect(output.messages).toEqual([]); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => + withShell( + { + SUPABASE_EXPERIMENTAL_PG_DELTA: "true", + SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false", + }, + effect, + ), + Effect.scoped, + ), + ); + + it.live("fails the load with the env name, tier and key when an override does not decode", () => + Effect.gen(function* () { + const root = yield* project('project_id = "bad"\n'); + const layer = configValuesLayer(); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "CliConfigValueError", + path: "api.port", + tier: "shell", + envName: "SUPABASE_API_PORT", + }); + expect(error.message).toBe( + 'Invalid SUPABASE_API_PORT="not-a-port" (sets api.port): expected a port (0-65535).', + ); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_API_PORT: "not-a-port" }, effect), + Effect.scoped, + ), + ); + + it.live("names the flag when a flag value does not decode", () => + Effect.gen(function* () { + const root = yield* project('project_id = "bad"\n'); + const layer = configValuesLayer({ flags: [flagInput("api.port", "port", 70000)] }); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ path: "api.port", tier: "flag", flag: "port" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("fails the first load when two flags set one key to different values", () => + Effect.gen(function* () { + const root = yield* project('project_id = "bad"\n'); + const layer = configValuesLayer({ + flags: [ + flagInput("db.seed.enabled", "no-seed", false), + flagInput("db.seed.enabled", "sql-paths", true), + ], + }); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "CliConfigFlagConflictError", + path: "db.seed.enabled", + flags: ["no-seed", "sql-paths"], + message: "--no-seed and --sql-paths both set db.seed.enabled; pass only one", + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("loads when two flags set one key to the same value", () => + Effect.gen(function* () { + const root = yield* project('project_id = "ok"\n'); + const layer = configValuesLayer({ + flags: [ + flagInput("db.seed.enabled", "include-seed", true), + flagInput("db.seed.enabled", "sql-paths", true), + ], + }); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + + expect((yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).value).toBe(true); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("keeps package errors visible by their own tag", () => + Effect.gen(function* () { + const root = yield* project("[db\nport = "); + const layer = configValuesLayer(); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error._tag).toBe("CliConfigParseError"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live.each(["[]", "42", '"text"', "null"])( + "fails the load when config.json holds %s instead of a table", + (content) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* project('project_id = "ok"\n'); + yield* fs.remove(path.join(root, "supabase", "config.toml")); + yield* fs.writeFileString(path.join(root, "supabase", "config.json"), content); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(configValuesLayer()), Effect.flip); + + expect(error._tag).toBe("CliConfigParseError"); + expect(error).toMatchObject({ path: path.join(root, "supabase", "config.json") }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("fails the load when a config value does not decode", () => + Effect.gen(function* () { + const root = yield* project("[db]\nport = 70000\n"); + const layer = configValuesLayer(); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ path: "db.port", tier: "config" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); +}); + +describe("CliConfigValues secrets", () => { + const captcha = (secret: string) => + `project_id = "secrets"\n[auth.captcha]\nenabled = true\nprovider = "hcaptcha"\nsecret = "${secret}"\n`; + + const readCaptcha = (root: string) => + Effect.gen(function* () { + const layer = configValuesLayer(); + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + return { + got: yield* resolvedConfig.get(CliConfigKeys.auth.captcha.secret), + decoded: resolvedConfig.materialized.config.auth.captcha?.secret, + }; + }); + + it.live("writes a decrypted config secret into the materialized config and the read", () => + Effect.gen(function* () { + const root = yield* project(captcha(CIPHERTEXT)); + + const { got, decoded } = yield* readCaptcha(root); + + expect(got).toMatchObject({ value: Option.some("value"), origin: { tier: "config" } }); + expect(decoded).toBe("value"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ DOTENV_PRIVATE_KEY: PRIVATE_KEY }, effect), + Effect.scoped, + ), + ); + + it.live("decrypts with a shell-only DOTENV_PRIVATE_KEY whose numeric suffix is large", () => + Effect.gen(function* () { + const root = yield* project(captcha(CIPHERTEXT)); + + const { decoded } = yield* readCaptcha(root); + + expect(decoded).toBe("value"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ DOTENV_PRIVATE_KEY_2026: PRIVATE_KEY }, effect), + Effect.scoped, + ), + ); + + it.live("tries every shell-only DOTENV_PRIVATE_KEY suffix, not just the highest", () => + Effect.gen(function* () { + const root = yield* project(captcha(CIPHERTEXT)); + + const { decoded } = yield* readCaptcha(root); + + expect(decoded).toBe("value"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => + withShell( + { + DOTENV_PRIVATE_KEY_2025: PRIVATE_KEY, + DOTENV_PRIVATE_KEY_2026: "11".repeat(32), + }, + effect, + ), + Effect.scoped, + ), + ); + + it.live("decrypts ciphertext reached through an env() reference", () => + Effect.gen(function* () { + const root = yield* project(captcha("env(CAPTCHA_CIPHERTEXT)"), { + supabaseEnv: `CAPTCHA_CIPHERTEXT=${CIPHERTEXT}\n`, + }); + + const { got, decoded } = yield* readCaptcha(root); + + expect(got.value).toEqual(Option.some("value")); + expect(decoded).toBe("value"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ DOTENV_PRIVATE_KEY: PRIVATE_KEY }, effect), + Effect.scoped, + ), + ); + + it.live("fails the load instead of passing ciphertext through when decryption fails", () => + Effect.gen(function* () { + const root = yield* project(captcha(CIPHERTEXT)); + const layer = configValuesLayer(); + + const error = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ path: "auth.captcha.secret", tier: "config" }); + expect(error.message).toBe("failed to parse config: missing private key"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("keeps a withheld env value out of env() interpolation", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "scoped"\n[auth]\nsite_url = "env(SUPABASE_DB_PASSWORD)"\n', + ); + yield* link(root, LINKED); + const layer = configValuesLayer(); + const siteUrl = (ref: string) => + CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(ref) }), + ).pipe( + Effect.provide(layer), + Effect.map((resolvedConfig) => resolvedConfig.materialized.config.auth.site_url), + ); + + expect(yield* siteUrl(LINKED)).toBe("from-shell"); + expect(yield* siteUrl(OTHER)).toBe("env(SUPABASE_DB_PASSWORD)"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_DB_PASSWORD: "from-shell" }, effect), + Effect.scoped, + ), + ); + + it.live("treats an env() reference to an inherited property name as an unset variable", () => + Effect.gen(function* () { + const root = yield* project('project_id = "inherited"\n[auth]\nsite_url = "env(toString)"\n'); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(configValuesLayer())); + + expect(resolvedConfig.materialized.config.auth.site_url).toBe("env(toString)"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("reads a project .env line named like an inherited property", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "inherited"\n[auth]\nsite_url = "env(constructor)"\n', + { supabaseEnv: "constructor=http://ok\n" }, + ); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(configValuesLayer())); + + expect(resolvedConfig.projectEnvValues["constructor"]).toBe("http://ok"); + expect(resolvedConfig.materialized.config.auth.site_url).toBe("http://ok"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); +}); + +describe("CliConfigValues reads", () => { + it.live("normalizes default-tier values with and without a config file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const empty = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-cli-config-empty-" }); + const configured = yield* project('project_id = "seeded"\n'); + const layer = configValuesLayer(); + const read = (workdir: string) => + CliConfigValues.use((values) => + Effect.flatMap(values.load({ workdir, projectRef: Option.none() }), (resolvedConfig) => + resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths), + ), + ).pipe(Effect.provide(layer)); + + expect((yield* read(empty)).value).toEqual(["supabase/seed.sql"]); + expect((yield* read(configured)).value).toEqual(["supabase/seed.sql"]); + expect((yield* read(configured)).origin).toEqual({ tier: "default" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("keeps the unnormalized value beside a normalized config value", () => + Effect.gen(function* () { + const root = yield* project('project_id = "seeded"\n[db.seed]\nsql_paths = ["./a.sql"]\n'); + const layer = configValuesLayer(); + + const read = yield* CliConfigValues.use((values) => + Effect.flatMap( + values.load({ workdir: root, projectRef: Option.none() }), + (resolvedConfig) => resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths), + ), + ).pipe(Effect.provide(layer)); + + expect(read).toMatchObject({ + value: ["supabase/a.sql"], + unnormalized: ["./a.sql"], + origin: { tier: "config" }, + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("defaults project_id from the workdir name, never the target ref", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-cli-config-" }); + const root = path.join(parent, "My Project"); + yield* fs.makeDirectory(path.join(root, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "supabase", "config.toml"), "[db]\nport = 54399\n"); + const layer = configValuesLayer(); + + const read = yield* CliConfigValues.use((values) => + Effect.flatMap( + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + (resolvedConfig) => resolvedConfig.get(CliConfigKeys.projectId), + ), + ).pipe(Effect.provide(layer)); + + expect(read.value).toBe("My_Project"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("coerces weakly typed config values into the decoded config and an env() bool", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "weak"\n[db.seed]\nenabled = "TRUE"\nsql_paths = "a.sql,b.sql"\n[db.pooler]\nenabled = "env(POOLER_ON)"\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.materialized.config.db.seed.enabled).toBe(true); + expect(resolvedConfig.materialized.config.db.seed.sql_paths).toEqual([ + "supabase/a.sql", + "supabase/b.sql", + ]); + expect((yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).value).toBe(true); + expect((yield* resolvedConfig.get(CliConfigKeys.db.pooler.enabled)).value).toBe(true); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ POOLER_ON: "true" }, effect), + Effect.scoped, + ), + ); + + it.live("coerces weakly typed config values in the file-only view without the env overlay", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "weak"\n[api]\nmax_rows = "10"\n[db.seed]\nenabled = "TRUE"\n[db.pooler]\nenabled = "env(POOLER_ON)"\n[experimental.pgdelta]\nenabled = 0\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + + expect(declared.config.db.seed.enabled).toBe(true); + expect(declared.config.api.max_rows).toBe(10); + expect(declared.config.db.pooler.enabled).toBe(true); + expect(declared.config.experimental.pgdelta?.enabled).toBe(false); + expect((yield* resolvedConfig.get(CliConfigKeys.db.seed.enabled)).value).toBe(false); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ POOLER_ON: "true", SUPABASE_DB_SEED_ENABLED: "false" }, effect), + Effect.scoped, + ), + ); + + it.live("sanitizes project_id once, whichever tier supplies it", () => + Effect.gen(function* () { + const root = yield* project('project_id = "my app"\n'); + const layer = configValuesLayer(); + const read = CliConfigValues.use((values) => + Effect.flatMap( + values.load({ workdir: root, projectRef: Option.none() }), + (resolvedConfig) => resolvedConfig.get(CliConfigKeys.projectId), + ), + ).pipe(Effect.provide(layer)); + + expect(yield* read).toMatchObject({ value: "my_app", origin: { tier: "config" } }); + const fromEnv = yield* withShell({ SUPABASE_PROJECT_ID: "other app" }, read); + expect(fromEnv).toMatchObject({ value: "other_app", origin: { tier: "shell" } }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("reads the same value through get and the materialized config", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "agree"\n[api]\nport = 54399\n[auth.email.smtp]\nhost = "smtp.test"\nport = 587\nuser = "u"\npass = "p"\nadmin_email = "a@b.test"\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + const { config } = resolvedConfig.materialized; + + expect((yield* resolvedConfig.get(CliConfigKeys.api.port)).value).toBe(config.api.port); + expect((yield* resolvedConfig.get(CliConfigKeys.db.port)).value).toBe(config.db.port); + expect((yield* resolvedConfig.get(CliConfigKeys.auth.email.smtp.host)).value).toEqual( + Option.some(config.auth.email.smtp?.host), + ); + expect((yield* resolvedConfig.get(CliConfigKeys.auth.email.smtp.enabled)).value).toBe( + config.auth.email.smtp?.enabled, + ); + expect((yield* resolvedConfig.get(CliConfigKeys.projectId)).value).toBe(config.project_id); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("resolves every registry key to the value the materialized config holds", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "parity"\n[api]\nport = 54399\n[db.seed]\nsql_paths = ["./a.sql"]\n[auth.email.smtp]\nhost = "smtp.test"\nport = 587\nuser = "u"\npass = "p"\nadmin_email = "a@b.test"\n[auth.hook.send_email]\nenabled = true\nuri = "pg-functions://postgres/public/send"\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + + const mismatches: Array = []; + let compared = 0; + for (const key of cliConfigRegistry.keys) { + if (key.document === false || cliConfigDocumentOnlyPaths.has(key.path)) continue; + compared += 1; + const resolved = key.toDocument((yield* resolvedConfig.get(key)).value); + const materialized = key.path + .split(".") + .reduce( + (node, segment) => + typeof node === "object" && node !== null ? Reflect.get(node, segment) : undefined, + resolvedConfig.materialized.config, + ); + if (JSON.stringify(resolved) !== JSON.stringify(materialized)) mismatches.push(key.path); + } + + expect(compared).toBeGreaterThan(100); + expect(mismatches).toEqual([]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("lists family entries from the registry and the merged document", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "families"\n[auth.email.template.invite]\nsubject = "Join"\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + + expect(resolvedConfig.familyNames("authEmailTemplate")).toEqual(["invite"]); + expect(resolvedConfig.familyNames("authExternal")).toContain("github"); + expect(resolvedConfig.familyNames("authHook")).toContain("send_sms"); + expect( + (yield* resolvedConfig.get(familyKey("authEmailTemplate", "invite", "subject"))).value, + ).toEqual(Option.some("Join")); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live( + "resolves env names with the shell before the project env file, omitting unset ones", + () => + Effect.gen(function* () { + const root = yield* project('project_id = "lookup"\n', { + supabaseEnv: "SUPABASE_UNOWNED_FROM_FILE=file\nSUPABASE_API_PORT=1111\n", + }); + const layer = configValuesLayer(); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.none() }), + ).pipe(Effect.provide(layer)); + + expect( + yield* resolvedConfig.envValues([ + "SUPABASE_UNOWNED_FROM_SHELL", + "SUPABASE_UNOWNED_FROM_FILE", + "SUPABASE_UNOWNED_UNSET", + "SUPABASE_API_PORT", + ]), + ).toEqual({ + SUPABASE_UNOWNED_FROM_SHELL: "shell", + SUPABASE_UNOWNED_FROM_FILE: "file", + SUPABASE_API_PORT: "2222", + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => + withShell({ SUPABASE_UNOWNED_FROM_SHELL: "shell", SUPABASE_API_PORT: "2222" }, effect), + Effect.scoped, + ), + ); +}); + +describe("CliConfigValues dynamic family names", () => { + const POLLUTING = "SUPABASE_AUTH_EMAIL_TEMPLATE___PROTO___POLLUTED_SUBJECT"; + const polluting = '[auth.email.template."__proto__.polluted"]\nsubject = "file"\n'; + + const templates = (root: string) => + CliConfigValues.use((values) => + Effect.map( + values.load({ workdir: root, projectRef: Option.none() }), + (resolvedConfig) => + resolvedConfig.loaded.config.auth.email.template as Record< + string, + { readonly subject?: string } | undefined + >, + ), + ).pipe(Effect.provide(configValuesLayer())); + + const cleanPrototype = Effect.sync(() => { + delete (Object.prototype as Record)["polluted"]; + }); + + it.live("keeps a shell override for a prototype-like template name out of Object.prototype", () => + Effect.gen(function* () { + const root = yield* project(`project_id = "proto"\n${polluting}`); + + const decoded = yield* templates(root); + + expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); + expect(decoded["__proto__.polluted"]?.subject).toBe("override"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ [POLLUTING]: "override" }, effect), + Effect.ensuring(cleanPrototype), + Effect.scoped, + ), + ); + + it.live( + "keeps a project .env override for a prototype-like template name out of Object.prototype", + () => + Effect.gen(function* () { + const root = yield* project(`project_id = "proto"\n${polluting}`, { + supabaseEnv: `${POLLUTING}=override\n`, + }); + + const decoded = yield* templates(root); + + expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); + expect(decoded["__proto__.polluted"]?.subject).toBe("override"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({}, effect), + Effect.ensuring(cleanPrototype), + Effect.scoped, + ), + ); + + for (const name of ["constructor", "prototype"]) { + it.live(`overrides a template named ${name} on its own table`, () => + Effect.gen(function* () { + const root = yield* project( + `project_id = "proto"\n[auth.email.template.${name}]\nsubject = "file"\n`, + ); + + const decoded = yield* templates(root); + + expect(Object.hasOwn(Object.prototype, "subject")).toBe(false); + expect(Object.hasOwn(decoded, name) ? decoded[name]?.subject : undefined).toBe("override"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => + withShell( + { [`SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}_SUBJECT`]: "override" }, + effect, + ), + Effect.scoped, + ), + ); + } + + it.live("keeps a shell override for a bare __proto__ template name as an own key", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "proto"\n[auth.email.template.__proto__]\nsubject = "file"\n', + ); + + const decoded = yield* templates(root); + + expect(Object.hasOwn(decoded, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(decoded, "__proto__")?.value?.subject).toBe( + "override", + ); + expect(Object.hasOwn(Object.prototype, "subject")).toBe(false); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_AUTH_EMAIL_TEMPLATE___PROTO___SUBJECT: "override" }, effect), + Effect.scoped, + ), + ); + + it.live("applies an override to a dotted template name without creating a phantom table", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "dotted"\n[auth.email.template."a.b"]\nsubject = "file"\n', + ); + + const decoded = yield* templates(root); + + expect(decoded["a.b"]?.subject).toBe("fromenv"); + expect(Object.hasOwn(decoded, "a")).toBe(false); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_AUTH_EMAIL_TEMPLATE_A_B_SUBJECT: "fromenv" }, effect), + Effect.scoped, + ), + ); +}); + +describe("CliConfigValues.materialized", () => { + const richConfig = `project_id = "rich" + +[api] +port = 54321 +schemas = ["public"] +max_rows = 500 + +[db] +port = 54322 +major_version = 15 +health_timeout = "3m" + +[db.pooler] +enabled = true +port = 54329 + +[db.settings] +max_connections = 50 + +[auth] +enable_signup = true +site_url = "http://localhost:3000" +jwt_expiry = 3600 +minimum_password_length = 8 + +[auth.email.smtp] +host = "smtp.example.com" +port = 587 +user = "mailer" +pass = "env(SMTP_PASS)" +admin_email = "admin@example.com" + +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "captcha-secret" + +[auth.mfa.totp] +enroll_enabled = false + +[auth.hook.custom_access_token] +enabled = true +uri = "pg-functions://postgres/public/custom_access_token_hook" + +[auth.hook.send_sms] +enabled = true +uri = "https://sms.example.com/hook" +secrets = "env(SEND_SMS_SECRET)" + +[auth.external.github] +enabled = true +client_id = "gh-client" +secret = "env(GITHUB_SECRET)" + +[auth.email.notification.email_changed] +enabled = true +subject = "Email changed" + +[storage] +file_size_limit = "50MiB" + +[studio] +port = 54323 + +[realtime] +ip_version = "IPv4" + +[edge_runtime] +policy = "per_worker" +`; + + const dotenv = [ + "SMTP_PASS=from-dotenv", + "SEND_SMS_SECRET=v1,whsec_c2VuZC1zbXMtc2VjcmV0LWJhc2U2NA==", + "GITHUB_SECRET=gh-secret", + "SUPABASE_API_PORT=54421", + "SUPABASE_API_SCHEMAS=public,extra", + "SUPABASE_API_MAX_ROWS=0x20", + "SUPABASE_DB_PORT=54422", + "SUPABASE_DB_MAJOR_VERSION=17", + "SUPABASE_DB_HEALTH_TIMEOUT=5m", + "SUPABASE_DB_POOLER_ENABLED=false", + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS=100", + "SUPABASE_AUTH_ENABLE_SIGNUP=false", + "SUPABASE_AUTH_SITE_URL=http://example.test", + "SUPABASE_AUTH_JWT_EXPIRY=7200", + "SUPABASE_AUTH_EMAIL_SMTP_HOST=smtp.override.test", + "SUPABASE_AUTH_CAPTCHA_PROVIDER=turnstile", + "SUPABASE_AUTH_CAPTCHA_SECRET=env-captcha-secret", + "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED=true", + "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/other_hook", + "SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID=env-gh-client", + "SUPABASE_AUTH_EMAIL_NOTIFICATION_EMAIL_CHANGED_SUBJECT=Changed by env", + "SUPABASE_STORAGE_FILE_SIZE_LIMIT=100MiB", + "SUPABASE_STUDIO_PORT=54523", + "SUPABASE_REALTIME_IP_VERSION=IPv6", + "SUPABASE_EDGE_RUNTIME_POLICY=oneshot", + ].join("\n"); + + const stackOverlay = (root: string, ref?: string) => + Effect.gen(function* () { + return (yield* loadStackConfig(root, ref === undefined ? undefined : { projectRef: ref })) + .source; + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer, cliConfigValuesTestLayer)), + ); + + const materialize = ( + root: string, + options: { + readonly ref?: string; + readonly flags?: ReadonlyArray; + } = {}, + ) => { + const layer = configValuesLayer({ flags: options.flags }); + return CliConfigValues.use((values) => + values.load({ + workdir: root, + projectRef: options.ref === undefined ? Option.none() : Option.some(options.ref), + }), + ).pipe( + Effect.provide(layer), + Effect.map((resolvedConfig) => resolvedConfig.materialized), + ); + }; + + const compareWithOverlay = ( + config: CliConfig, + overlay: Effect.Success>, + ) => { + expect(config.api).toEqual(overlay.api); + expect(config.db).toEqual({ + ...overlay.db, + seed: { ...overlay.db.seed, sql_paths: ["supabase/seed.sql"] }, + }); + expect(config.studio).toEqual(overlay.studio); + expect(config.realtime).toEqual(overlay.realtime); + expect(config.edge_runtime).toEqual(overlay.edge_runtime); + expect(config.storage).toEqual(overlay.storage); + expect(config.auth).toEqual(overlay.auth); + }; + + it.live("agrees with the stack config overlay, including secrets, families and hooks", () => + Effect.gen(function* () { + const root = yield* project(richConfig, { supabaseEnv: `${dotenv}\n` }); + + const overlay = yield* stackOverlay(root); + const { config, originAt } = yield* materialize(root); + + compareWithOverlay(config, overlay); + expect(config.auth.hook.custom_access_token).toMatchObject({ + enabled: true, + uri: "pg-functions://postgres/public/other_hook", + }); + expect(config.auth.hook.mfa_verification_attempt).toMatchObject({ + enabled: false, + uri: "", + secrets: "", + }); + expect(config.auth.external["github"]).toMatchObject({ + client_id: "env-gh-client", + secret: "gh-secret", + }); + expect(config.auth.email.smtp?.pass).toBe("from-dotenv"); + expect(config.auth.captcha?.secret).toBe("env-captcha-secret"); + expect(originAt("api.port")).toMatchObject({ + tier: "projectEnv", + envName: "SUPABASE_API_PORT", + }); + expect(originAt("auth.email.smtp.port")).toMatchObject({ tier: "config" }); + expect(originAt("db.password")).toEqual({ tier: "default" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("agrees with the stack config overlay for a matched remote and shell overrides", () => + Effect.gen(function* () { + const root = yield* project( + `${richConfig} +[remotes.staging] +project_id = "${LINKED}" + +[remotes.staging.api] +schemas = ["public", "remote_api"] + +[remotes.staging.auth] +site_url = "http://remote.example.com" +enable_signup = false + +[remotes.staging.db] +major_version = 15 +`, + { + supabaseEnv: + "SMTP_PASS=from-dotenv\nSEND_SMS_SECRET=v1,whsec_c2VuZC1zbXMtc2VjcmV0LWJhc2U2NA==\nGITHUB_SECRET=gh\n", + }, + ); + yield* link(root, LINKED); + + const overlay = yield* stackOverlay(root, LINKED); + const { config, originAt } = yield* materialize(root, { ref: LINKED }); + + compareWithOverlay(config, overlay); + expect(config.api.schemas).toEqual(["public", "shell_api"]); + expect(config.auth.site_url).toBe("http://remote.example.com"); + expect(originAt("auth.site_url")).toMatchObject({ tier: "config", remote: "staging" }); + expect(originAt("api.schemas")).toMatchObject({ tier: "shell" }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_API_SCHEMAS: "public,shell_api" }, effect), + Effect.scoped, + ), + ); + + it.live("lets flags beat every other tier and applies the key's normalizer once", () => + Effect.gen(function* () { + const root = yield* project(richConfig, { + supabaseEnv: + "SMTP_PASS=p\nSEND_SMS_SECRET=v1,whsec_c2VuZC1zbXMtc2VjcmV0LWJhc2U2NA==\nGITHUB_SECRET=g\nSUPABASE_DB_SEED_ENABLED=false\n", + }); + + const { config, originAt } = yield* materialize(root, { + flags: [ + flagInput("db.seed.enabled", "include-seed", true), + flagInput("db.seed.sql_paths", "sql-paths", ["./flag.sql"]), + flagInput("experimental.pgdelta.enabled", "use-pg-delta", true), + ], + }); + + expect(config.db.seed.enabled).toBe(true); + expect(config.db.seed.sql_paths).toEqual(["supabase/flag.sql"]); + expect(config.experimental.pgdelta?.enabled).toBe(true); + expect(originAt("db.seed.enabled")).toEqual({ tier: "flag", flag: "include-seed" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("leaves a base-only value under an applied remote without a remote origin", () => + Effect.gen(function* () { + const root = yield* project( + `[[experimental.inspect.rules]] +name = "base-rule" +query = "select 1" + +[remotes.staging] +project_id = "${LINKED}" + +[remotes.staging.db] +major_version = 15 +`, + ); + yield* link(root, LINKED); + + const { originAt } = yield* materialize(root, { ref: LINKED }); + + const origin = originAt("experimental.inspect.rules"); + expect(origin).toMatchObject({ tier: "config" }); + expect(origin).not.toHaveProperty("remote"); + expect(origin).toHaveProperty("file", expect.stringContaining("config.toml")); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("decodes defaults when the workdir has no config file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-cli-config-empty-" }); + + const { config, originAt } = yield* materialize(root); + + expect(config.db.port).toBe(54322); + expect(config.db.seed.sql_paths).toEqual(["supabase/seed.sql"]); + expect(originAt("db.port")).toEqual({ tier: "default" }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); +}); + +describe("CliConfigValues loaded document", () => { + const load = ( + root: string, + target: Parameters[0]["projectRef"], + ) => CliConfigValues.use((values) => values.load({ workdir: root, projectRef: target })); + + it.live("exposes the declared document without the defaults materialized for consumers", () => + Effect.gen(function* () { + const root = yield* project('project_id = "declared"\n[api]\nmax_rows = 10\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const { loaded } = resolvedConfig; + + expect(loaded.document).toEqual({ project_id: "declared", api: { max_rows: 10 } }); + expect(loaded.config.api.max_rows).toBe(10); + expect(resolvedConfig.materialized.config.auth.hook?.send_email?.uri).toBe(""); + expect(loaded.config.auth.hook?.send_email?.uri).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("matches the package loader when no flag or env overrides anything", () => + Effect.gen(function* () { + const root = yield* project( + '[db.seed]\nsql_paths = ["./a.sql", "./seeds/*.sql"]\n[auth.hook.send_email]\nenabled = true\nuri = "pg-functions://postgres/public/send"\n[api]\nmax_rows = 10\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const packaged = yield* loadCliConfig(root, { cliCompat: true }); + + expect(packaged).not.toBeNull(); + expect(resolvedConfig.loaded).toEqual(packaged); + expect(resolvedConfig.loaded.document).not.toHaveProperty("project_id"); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("applies an env override to the loaded config", () => + Effect.gen(function* () { + const root = yield* project('project_id = "declared"\n[api]\nmax_rows = 10\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.loaded.config.api.max_rows).toBe(25); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_API_MAX_ROWS: "25" }, effect), + Effect.scoped, + ), + ); + + it.live("reports no config file when the workdir has none", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-cli-config-none-" }); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.hasConfigFile).toBe(false); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("fails on an unreadable .temp/project-ref unless the load tolerates it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* project('project_id = "declared"\n'); + yield* fs.makeDirectory(path.join(root, "supabase", ".temp", "project-ref"), { + recursive: true, + }); + const layer = configValuesLayer(); + + const strict = yield* load(root, Option.some(LINKED)).pipe( + Effect.provide(layer), + Effect.exit, + ); + const tolerant = yield* CliConfigValues.use((values) => + values.load({ + workdir: root, + projectRef: Option.some(LINKED), + tolerateUnreadableLinkedRef: true, + }), + ).pipe(Effect.provide(layer)); + + expect(Exit.isFailure(strict)).toBe(true); + expect(tolerant.withheldEnv).toEqual([]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("names the source of an invalid value and leaves it out when tolerated", () => + Effect.gen(function* () { + const root = yield* project( + `project_id = "declared" +[db] +port = "not-a-port" +[remotes.staging] +project_id = "${LINKED}" +`, + ); + const layer = configValuesLayer(); + + const failure = yield* load(root, Option.some(LINKED)).pipe( + Effect.flip, + Effect.provide(layer), + ); + const tolerated = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED), tolerateInvalid: true }), + ).pipe(Effect.provide(layer)); + + expect(failure).toMatchObject({ + _tag: "CliConfigValueError", + path: "db.port", + source: "supabase/config.toml", + message: 'Invalid db.port in supabase/config.toml: "not-a-port" is not a port (0-65535).', + }); + expect(tolerated.invalid.map((entry) => entry.path)).toEqual(["db.port"]); + expect(tolerated.materialized.config.db.port).toBe(54322); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("loads a partial [auth.email.smtp] table with smtp off", () => + Effect.gen(function* () { + const partial = yield* project('[auth.email.smtp]\npass = "env(SMTP_PASS)"\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(partial, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.materialized.config.auth.email.smtp?.enabled).toBe(false); + expect(resolvedConfig.declares("auth.email.smtp")).toBe(true); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SMTP_PASS: "secret" }, effect), + Effect.scoped, + ), + ); + + it.live("keeps the env variable as the origin of an env() reference on a numeric key", () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n[api]\nmax_rows = "env(ROWS)"\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.loaded.config.api.max_rows).toBe(40); + expect(resolvedConfig.loaded.valueOrigins).toContainEqual({ + path: ["api", "max_rows"], + source: "environment", + envVariables: ["ROWS"], + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ ROWS: "40" }, effect), + Effect.scoped, + ), + ); + + it.live("keeps the env variable as the origin of an env() reference inside an array", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "p"\n[auth]\nadditional_redirect_urls = ["env(CALLBACK_URL)"]\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + + const origin = { + path: ["auth", "additional_redirect_urls"], + source: "environment", + envVariables: ["CALLBACK_URL"], + }; + expect(resolvedConfig.loaded.config.auth.additional_redirect_urls).toEqual([ + "https://cb.example.com", + ]); + expect(resolvedConfig.loaded.valueOrigins).toContainEqual(origin); + expect(declared.valueOrigins).toContainEqual(origin); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ CALLBACK_URL: "https://cb.example.com" }, effect), + Effect.scoped, + ), + ); + + it.live("coerces the other entries of a list holding an env() reference", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "p"\n[db.seed]\nsql_paths = ["env(SEED_PATH)", 42, true]\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + const got = yield* resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths); + + expect(got.unnormalized).toEqual(["seed.sql", "42", "1"]); + expect(resolvedConfig.loaded.config.db.seed.sql_paths).toEqual(["seed.sql", "42", "1"]); + expect(declared.config.db.seed.sql_paths).toEqual(["seed.sql", "42", "1"]); + expect(resolvedConfig.materialized.config.db.seed.sql_paths).toEqual(got.value); + const origin = { + path: ["db", "seed", "sql_paths"], + source: "environment", + envVariables: ["SEED_PATH"], + }; + expect(resolvedConfig.loaded.valueOrigins).toContainEqual(origin); + expect(declared.valueOrigins).toContainEqual(origin); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SEED_PATH: "seed.sql" }, effect), + Effect.scoped, + ), + ); + + it.live("keeps an unset env() reference literal in a mixed list, with a local origin", () => + Effect.gen(function* () { + const root = yield* project( + 'project_id = "p"\n[db.seed]\nsql_paths = ["env(SEED_PATH)", 42]\n', + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const got = yield* resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths); + + expect(got.unnormalized).toEqual(["env(SEED_PATH)", "42"]); + expect(resolvedConfig.loaded.config.db.seed.sql_paths).toEqual(["env(SEED_PATH)", "42"]); + expect(resolvedConfig.loaded.valueOrigins).toContainEqual({ + path: ["db", "seed", "sql_paths"], + source: "local", + }); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("coerces a mixed list with an env() reference in a matched remote block", () => + Effect.gen(function* () { + const root = yield* project( + `project_id = "p" +[remotes.staging] +project_id = "${LINKED}" + +[remotes.staging.db.seed] +sql_paths = ["env(SEED_PATH)", 42] +`, + ); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.some(LINKED)).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + const got = yield* resolvedConfig.get(CliConfigKeys.db.seed.sqlPaths); + + expect(resolvedConfig.appliedRemote).toEqual(Option.some("staging")); + expect(got.unnormalized).toEqual(["seed.sql", "42"]); + expect(resolvedConfig.loaded.config.db.seed.sql_paths).toEqual(["seed.sql", "42"]); + expect(declared.config.db.seed.sql_paths).toEqual(["seed.sql", "42"]); + expect(resolvedConfig.materialized.config.db.seed.sql_paths).toEqual(got.value); + expect(resolvedConfig.loaded.valueOrigins).toContainEqual({ + path: ["db", "seed", "sql_paths"], + source: "environment", + envVariables: ["SEED_PATH"], + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SEED_PATH: "seed.sql" }, effect), + Effect.scoped, + ), + ); + + for (const [rows, expected] of [ + ["010", 8], + ["1_000", 1000], + ] as const) { + it.live(`reads an env() reference to ${rows} on a numeric key the same in every view`, () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n[api]\nmax_rows = "env(ROWS)"\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + + expect((yield* resolvedConfig.get(CliConfigKeys.api.maxRows)).value).toBe(expected); + expect(resolvedConfig.loaded.config.api.max_rows).toBe(expected); + expect(resolvedConfig.materialized.config.api.max_rows).toBe(expected); + expect(declared.config.api.max_rows).toBe(expected); + const origin = { path: ["api", "max_rows"], source: "environment", envVariables: ["ROWS"] }; + expect(resolvedConfig.loaded.valueOrigins).toContainEqual(origin); + expect(declared.valueOrigins).toContainEqual(origin); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ ROWS: rows }, effect), + Effect.scoped, + ), + ); + } + + it.live( + "keeps the env variable as the origin of an env() reference in a config-tier secret", + () => + Effect.gen(function* () { + const root = yield* project('[auth]\njwt_secret = "env(JWT)"\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.loaded.config.auth.jwt_secret).toBe("s3cret"); + expect(resolvedConfig.loaded.valueOrigins).toContainEqual({ + path: ["auth", "jwt_secret"], + source: "environment", + envVariables: ["JWT"], + }); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ JWT: "s3cret" }, effect), + Effect.scoped, + ), + ); + + it.live("declares in the file only what the file sets, ignoring a SUPABASE_* override", () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n[api]\nmax_rows = 10\n'); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const declared = yield* resolvedConfig.fileDeclared; + + expect(resolvedConfig.loaded.config.api.max_rows).toBe(25); + expect(declared.config.api.max_rows).toBe(10); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ SUPABASE_API_MAX_ROWS: "25" }, effect), + Effect.scoped, + ), + ); + + it.live("expands a chained env reference once across get, loaded and materialized", () => + Effect.gen(function* () { + const root = yield* project('[auth]\nsite_url = "http://from-config"\n', { + supabaseEnv: "CHAIN_W=w\n", + }); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const got = yield* resolvedConfig.get(CliConfigKeys.auth.siteUrl); + + expect(got.value).toBe("env(CHAIN_W)"); + expect(resolvedConfig.loaded.config.auth.site_url).toBe("env(CHAIN_W)"); + expect(resolvedConfig.materialized.config.auth.site_url).toBe("env(CHAIN_W)"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => + withShell({ SUPABASE_AUTH_SITE_URL: "env(CHAIN_Y)", CHAIN_Y: "env(CHAIN_W)" }, effect), + Effect.scoped, + ), + ); + + it.live("expands a chained env reference in a config-tier secret once", () => + Effect.gen(function* () { + const root = yield* project('[auth]\njwt_secret = "env(CHAIN_Y)"\n', { + supabaseEnv: "CHAIN_W=w\n", + }); + const layer = configValuesLayer(); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + const got = yield* resolvedConfig.get(CliConfigKeys.auth.jwtSecret); + + expect(got.value).toEqual(Option.some("env(CHAIN_W)")); + expect(resolvedConfig.loaded.config.auth.jwt_secret).toBe("env(CHAIN_W)"); + expect(resolvedConfig.materialized.config.auth.jwt_secret).toBe("env(CHAIN_W)"); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({ CHAIN_Y: "env(CHAIN_W)" }, effect), + Effect.scoped, + ), + ); + + it.live("keeps a flag literal that looks like an env reference as written", () => + Effect.gen(function* () { + const root = yield* project('project_id = "p"\n', { supabaseEnv: "CHAIN_W=w\n" }); + const layer = configValuesLayer({ + flags: [flagInput("db.seed.sql_paths", "sql-paths", ["env(CHAIN_W)"])], + }); + + const resolvedConfig = yield* load(root, Option.none()).pipe(Effect.provide(layer)); + + expect(resolvedConfig.loaded.config.db.seed.sql_paths).toEqual(["env(CHAIN_W)"]); + }).pipe(Effect.provide(BunServices.layer), (effect) => withShell({}, effect), Effect.scoped), + ); + + it.live("prints each deprecation warning once across targets and invalidation", () => { + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((arg) => String(arg)).join(" ")); + }); + return Effect.gen(function* () { + const root = yield* project( + `[inbucket] +enabled = true + +[auth.external.slack] +enabled = true +client_id = "a" +secret = "b" +`, + ); + const layer = configValuesLayer(); + + yield* Effect.gen(function* () { + const values = yield* CliConfigValues; + yield* values.load({ workdir: root, projectRef: Option.none() }); + yield* values.load({ workdir: root, projectRef: Option.some(LINKED) }); + yield* values.invalidate; + yield* values.load({ workdir: root, projectRef: Option.none() }); + }).pipe(Effect.provide(layer)); + + const warned = (needle: string) => warnings.filter((message) => message.includes(needle)); + expect(warned("config section [inbucket] is deprecated")).toHaveLength(1); + expect(warned('disabling deprecated "slack" provider')).toHaveLength(1); + }).pipe( + Effect.provide(BunServices.layer), + (effect) => withShell({}, effect), + Effect.scoped, + Effect.ensuring(Effect.sync(() => errorSpy.mockRestore())), + ); + }); +}); + +describe("CliConfigValues over a lookup-only provider", () => { + const shell: Readonly> = { + SUPABASE_DB_PORT: "54400", + CUSTOM_SITE: "https://custom.example", + SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "family-client", + SUPABASE_REMOTES_STAGING_PROJECT_ID: LINKED, + DOTENV_PRIVATE_KEY_2026: PRIVATE_KEY, + }; + const lookupOnly = ConfigProvider.make((path) => { + const value = shell[path.join("_")]; + return Effect.succeed(value === undefined ? undefined : ConfigProvider.makeValue(value)); + }); + + it.live("resolves registry, env() reference, family, remote id and dotenvx names by name", () => + Effect.gen(function* () { + const root = yield* project(`project_id = "lookup" +[auth] +site_url = "env(CUSTOM_SITE)" +[auth.captcha] +enabled = true +provider = "hcaptcha" +secret = "${CIPHERTEXT}" + +[remotes.staging] +project_id = "${OTHER}" +`); + const layer = cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs()), + ConfigProvider.layer(lookupOnly), + Layer.succeed(CliEnvNameSource, () => ["DOTENV_PRIVATE_KEY_2026"]), + ), + ), + ); + + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir: root, projectRef: Option.some(LINKED) }), + ).pipe(Effect.provide(layer)); + + expect(yield* resolvedConfig.get(CliConfigKeys.db.port)).toMatchObject({ + value: 54400, + origin: { tier: "shell" }, + }); + expect(resolvedConfig.materialized.config.auth.site_url).toBe("https://custom.example"); + expect( + yield* resolvedConfig.get(familyKey("authExternal", "apple", "client_id")), + ).toMatchObject({ value: "family-client", origin: { tier: "shell" } }); + expect(resolvedConfig.appliedRemote).toEqual(Option.some("staging")); + expect(resolvedConfig.materialized.config.auth.captcha?.secret).toBe("value"); + }).pipe(Effect.provide(BunServices.layer), Effect.scoped), + ); +}); diff --git a/apps/cli/src/config/cli-config-values.layer.ts b/apps/cli/src/config/cli-config-values.layer.ts new file mode 100644 index 0000000000..e3809220a5 --- /dev/null +++ b/apps/cli/src/config/cli-config-values.layer.ts @@ -0,0 +1,592 @@ +import type { CliConfigValueOrigin } from "@supabase/config"; +import { + decodeMergedCliConfig, + ENV_CAPTURE_REGEX, + mergeParsedCliConfig, + parseCliConfigDocumentFile, + type MergedCliConfigDocument, +} from "@supabase/config/internal"; +import { + Cache, + Console, + Data, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Result, +} from "effect"; + +import { readProjectRefFile } from "../shared/config/temp-paths.ts"; +import { collectDotenvPrivateKeys } from "../shared/config/vault-decrypt.ts"; +import { loadCliProjectEnvFiles, readShellEnvironment } from "../shared/config/cli-config-env.ts"; +import { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { CLI_CONFIG_FAMILIES } from "./cli-config-key-annotations.ts"; +import { + cloneDocumentRecord, + collectEnvReferences, + documentLeafPaths, + getDocumentValue, + isDocumentRecord, + pruneDocumentPaths, + sameDocumentValue, + setDocumentValue, +} from "./cli-config-document.ts"; +import { CliConfigFlagInputs, cliConfigFlagConflictError } from "./cli-config-flags.ts"; +import { + decodingFailedMessage, + describeCliConfigOrigin, + lookupCliConfigEnv, + pickCliConfigKey, + type CliConfigKeyOrigin, + type CliConfigSources, + type CliConfigValue, +} from "./cli-config-key.ts"; +import { + cliConfigFamilyEnvNames, + cliConfigFamilyKey, + cliConfigRegistry, + cliRemoteProjectIdEnvName, + staticCliConfigFamilyNames, + type AnyCliConfigKey, +} from "./cli-config-keys.ts"; +import { cliConfigRemoteFailure, selectCliConfigRemote } from "./cli-config-remote.ts"; +import { + CliConfigValues, + type CliConfigMaterialized, + type ResolvedCliConfig, + type CliConfigWithheldEnv, +} from "./cli-config-values.service.ts"; +import { CliConfigValueError } from "./cli-config.errors.ts"; + +class LoadKey extends Data.Class<{ + readonly workdir: string; + readonly projectRef: Option.Option; + readonly ignoreConfigFile: boolean; + readonly tolerateUnreadableLinkedRef: boolean; + readonly tolerateInvalid: boolean; +}> {} + +const emptyMergedDocument = (workdir: string, separator: string): MergedCliConfigDocument => ({ + path: `${workdir}${separator}supabase${separator}config.toml`, + format: "toml", + rawText: "", + schemaRef: undefined, + ignoredPaths: [], + rawDocument: {}, + document: {}, + appliedRemote: undefined, + remoteLeafPaths: [], +}); + +const registryEnvNames = cliConfigRegistry.keys.flatMap((key) => key.env); + +/** The env names the document makes relevant: `env()` references, remote ids and family entries. */ +const documentEnvNames = (rawDocument: Record): ReadonlySet => { + const names = new Set(); + collectEnvReferences(rawDocument, names); + const remotes = isDocumentRecord(rawDocument["remotes"]) ? rawDocument["remotes"] : {}; + for (const remote of Object.keys(remotes)) names.add(cliRemoteProjectIdEnvName(remote)); + for (const scope of [rawDocument, ...Object.values(remotes)]) { + for (const family of CLI_CONFIG_FAMILIES) { + const table = getDocumentValue(scope, family.prefix.split(".")); + if (!isDocumentRecord(table)) continue; + for (const entry of Object.keys(table)) { + for (const name of cliConfigFamilyEnvNames(family, entry)) names.add(name); + } + } + } + return names; +}; + +/** A config-tier value the schema would reject as written: `"TRUE"`, `0` for a bool, `"a,b"` for a list. */ +const needsCoercion = (key: AnyCliConfigKey, written: unknown, raw: unknown): boolean => + key.codec.kind !== "string" && !sameDocumentValue(written, raw); + +/** An `env(NAME)` value, or a list holding one. */ +const isEnvReference = (raw: unknown): boolean => + Array.isArray(raw) + ? raw.some(isEnvReference) + : typeof raw === "string" && ENV_CAPTURE_REGEX.test(raw); + +const declaredWrite = ( + key: AnyCliConfigKey, + picked: CliConfigValue, + raw: unknown, +): unknown => { + const written = key.toDocument(picked.unnormalized ?? picked.value); + switch (picked.origin.tier) { + case "flag": + case "shell": + case "projectEnv": + return written; + case "config": + return key.secret === true || isEnvReference(raw) || needsCoercion(key, written, raw) + ? written + : undefined; + case "default": + return undefined; + } +}; + +/** The coerced config-tier value a file-only view needs, or `undefined` when the file value decodes as written. */ +const fileWrite = (key: AnyCliConfigKey, sources: CliConfigSources): unknown => { + const raw = sources.config(key.segments)?.value; + if (raw === undefined || key.secret === true) return undefined; + const picked = pickCliConfigKey(key, sources, { configOnly: true }); + if (Result.isFailure(picked) || picked.success.origin.tier !== "config") return undefined; + const written = key.toDocument(picked.success.unnormalized ?? picked.success.value); + return isEnvReference(raw) || needsCoercion(key, written, raw) ? written : undefined; +}; + +/** The origin the decode would record for a resolved `env()` reference, which a pre-resolved path no longer gets. */ +const envOrigin = ( + key: AnyCliConfigKey, + raw: unknown, + lookup: (name: string) => string | undefined, +): CliConfigValueOrigin | undefined => { + const names = new Set(); + for (const item of Array.isArray(raw) ? raw : [raw]) { + const name = typeof item === "string" ? ENV_CAPTURE_REGEX.exec(item)?.[1] : undefined; + if (name !== undefined && lookup(name) !== undefined) names.add(name); + } + return names.size === 0 + ? undefined + : { path: [...key.segments], source: "environment", envVariables: [...names] }; +}; + +const withEnvOrigins = }>( + loaded: T, + injected: ReadonlyArray, +): T => + injected.length === 0 + ? loaded + : { + ...loaded, + valueOrigins: loaded.valueOrigins?.map( + (origin) => injected.find((env) => sameDocumentValue(env.path, origin.path)) ?? origin, + ), + }; + +const materializedWrite = ( + key: AnyCliConfigKey, + picked: CliConfigValue, + declared: unknown, +): unknown => { + if (picked.origin.tier === "default") { + return key.materializeDefault === true || key.normalize !== undefined + ? key.toDocument(picked.value) + : undefined; + } + return key.normalize === undefined ? declared : key.toDocument(picked.value); +}; + +export const cliConfigValuesLayer = Layer.effect( + CliConfigValues, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + const flagInputs = yield* CliConfigFlagInputs; + const debugLogger = yield* Effect.serviceOption(DebugLogger); + + const withPlatform = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const warned = new Set(); + const warnOnce = (message: string) => { + if (warned.has(message)) return Effect.void; + warned.add(message); + return output.warn(message); + }; + + const deprecationsSeen = new Set(); + const warnDeprecationOnce = (message: string) => { + if (deprecationsSeen.has(message)) return Effect.void; + deprecationsSeen.add(message); + return Console.error(message).pipe( + Effect.provideService(Console.Console, globalThis.console), + ); + }; + + const parsedDocuments = yield* Cache.makeWith( + (workdir: string) => + withPlatform( + parseCliConfigDocumentFile(workdir, { search: false, onWarning: warnDeprecationOnce }), + ), + { + capacity: 8, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, + ); + + const loadResolvedConfig = Effect.fn("CliConfigValues.load")(function* (target: LoadKey) { + const [conflict] = flagInputs.conflicts; + if (conflict !== undefined) return yield* cliConfigFlagConflictError(conflict); + + const parsed = target.ignoreConfigFile + ? null + : yield* Cache.get(parsedDocuments, target.workdir); + const rawDocument = parsed?.rawDocument ?? {}; + + const shell = yield* readShellEnvironment({ + names: [...registryEnvNames, "DOTENV_PRIVATE_KEY", ...documentEnvNames(rawDocument)], + namesWithPrefix: ["DOTENV_PRIVATE_KEY"], + }); + const projectEnv = yield* withPlatform(loadCliProjectEnvFiles(target.workdir, { shell })); + const valueReferences = new Set(); + collectEnvReferences(Object.values(projectEnv.values), valueReferences); + collectEnvReferences([...shell.entries().values()], valueReferences); + yield* shell.load(valueReferences); + + const targetRef = Option.getOrElse(target.projectRef, () => ""); + const linkedRef = Option.isNone(target.projectRef) + ? Option.none() + : target.tolerateUnreadableLinkedRef + ? yield* readProjectRefFile(fs, path, target.workdir).pipe( + Effect.orElseSucceed(() => Option.none()), + ) + : yield* readProjectRefFile(fs, path, target.workdir); + const foreignLinkedRef = Option.filter(linkedRef, (linked) => linked !== targetRef); + const scopedKeys = cliConfigRegistry.keys.filter((key) => key.envScope === "linkedTarget"); + const scopedNames = new Set(scopedKeys.flatMap((key) => key.env)); + const withheld = (name: string) => Option.isSome(foreignLinkedRef) && scopedNames.has(name); + + const withheldEnv: ReadonlyArray = Option.isNone(foreignLinkedRef) + ? [] + : scopedKeys.flatMap((key) => + key.env.flatMap((envName): ReadonlyArray => { + const shellValue = shell.get(envName); + const held = shellValue ?? projectEnv.values[envName]; + return held === undefined || held === "" + ? [] + : [ + { + path: key.path, + envName, + tier: shellValue === undefined ? "projectEnv" : "shell", + targetRef, + linkedRef: foreignLinkedRef.value, + }, + ]; + }), + ); + + const shellFor = (name: string) => (withheld(name) ? undefined : shell.get(name)); + const projectEnvFor = (name: string) => { + const value = projectEnv.values[name]; + if (withheld(name) || value === undefined) return undefined; + const file = projectEnv.files[name]; + return file === undefined ? { value } : { value, file }; + }; + const lookupEnv = (name: string) => + lookupCliConfigEnv({ shell: shellFor, projectEnv: projectEnvFor }, name); + + const remotes = parsed?.rawDocument?.["remotes"]; + if (isDocumentRecord(remotes)) { + const failure = cliConfigRemoteFailure(remotes, lookupEnv); + if (failure !== undefined) return yield* new CliConfigLoadError({ message: failure }); + } + + const merged = + parsed === null + ? null + : yield* mergeParsedCliConfig(parsed, { + selectRemote: (candidates) => + selectCliConfigRemote(candidates, target.projectRef, lookupEnv), + }); + + yield* Effect.annotateCurrentSpan({ + "config.found": merged !== null, + "config.remote_applied": merged?.appliedRemote !== undefined, + "config.env_withheld": Option.isSome(foreignLinkedRef), + }); + + const document = merged?.document; + const remoteLeaves = new Set(merged?.remoteLeafPaths.map((leaf) => leaf.join("."))); + const localLeaves = documentLeafPaths(merged?.rawDocument ?? {}); + const appliedRemote = merged?.appliedRemote; + + const configAtSegments = (segments: ReadonlyArray) => + getDocumentValue(document, segments); + const configAt = (configPath: string) => configAtSegments(configPath.split(".")); + const dotenvPrivateKeys = collectDotenvPrivateKeys({ + ...projectEnv.values, + ...Object.fromEntries(shell.entries()), + }); + + const sources: CliConfigSources = { + flags: (flagPath) => flagInputs.assignments.get(flagPath), + shell: shellFor, + projectEnv: projectEnvFor, + config: (segments) => { + const value = configAtSegments(segments); + if (value === undefined) return undefined; + const configPath = segments.join("."); + const referenced = + typeof value === "string" ? ENV_CAPTURE_REGEX.exec(value)?.[1] : undefined; + const source: "remote" | "local" = + remoteLeaves.has(configPath) || + (appliedRemote !== undefined && !localLeaves.has(configPath)) + ? "remote" + : "local"; + const origin = + referenced !== undefined && lookupEnv(referenced) !== undefined + ? { path: segments, source: "environment" as const, envVariables: [referenced] } + : { path: segments, source }; + return { + value, + origin, + ...(source === "remote" && appliedRemote !== undefined + ? { remote: appliedRemote } + : {}), + ...(merged === null ? {} : { file: merged.path }), + }; + }, + dotenvPrivateKeys, + context: { workdir: target.workdir, projectRef: target.projectRef, path, configAt }, + }; + + const familyNames: ResolvedCliConfig["familyNames"] = (id) => { + const family = cliConfigRegistry.families.find((candidate) => candidate.id === id); + if (family === undefined) return []; + const table = getDocumentValue(document, family.prefix.split(".")); + return [ + ...new Set([ + ...staticCliConfigFamilyNames(family), + ...(isDocumentRecord(table) ? Object.keys(table) : []), + ]), + ]; + }; + + const enumerated = new Map( + cliConfigRegistry.keys + .filter((key) => key.document !== false) + .map((key): [string, AnyCliConfigKey] => [key.path, key]), + ); + for (const family of cliConfigRegistry.families) { + for (const entry of familyNames(family.id)) { + for (const field of family.fields) { + const key = cliConfigFamilyKey(family, entry, field.name); + if (key !== undefined) enumerated.set(key.path, key); + } + } + } + + const declaredDraft = cloneDocumentRecord(document); + const materializedDraft = cloneDocumentRecord(declaredDraft); + const fileDraft = cloneDocumentRecord(document); + const origins = new Map(); + const invalid: Array = []; + const entryFailures: Array = []; + const overrideWarnings: Array = []; + const declaredResolved: Array> = []; + const materializedResolved: Array> = []; + const fileResolved: Array> = []; + const declaredEnvOrigins: Array = []; + const materializedEnvOrigins: Array = []; + const fileEnvOrigins: Array = []; + for (const key of enumerated.values()) { + const raw = sources.config(key.segments)?.value; + const configOrigin = envOrigin(key, raw, lookupEnv); + const fileValue = fileWrite(key, sources); + if (fileValue !== undefined) { + setDocumentValue(fileDraft, key.segments, fileValue); + fileResolved.push(key.segments); + if (configOrigin !== undefined) fileEnvOrigins.push(configOrigin); + } + const picked = pickCliConfigKey(key, sources); + if (Result.isFailure(picked)) { + if (target.tolerateInvalid) { + invalid.push(picked.failure); + pruneDocumentPaths(declaredDraft, [key.segments], document); + pruneDocumentPaths(materializedDraft, [key.segments], document); + } else if (picked.failure.issues === undefined) { + return yield* picked.failure; + } else { + entryFailures.push(picked.failure); + } + continue; + } + const { origin } = picked.success; + origins.set(key.path, origin); + if (origin.tier === "shell" || origin.tier === "projectEnv") { + if (appliedRemote !== undefined && remoteLeaves.has(key.path)) { + overrideWarnings.push( + `${describeCliConfigOrigin(origin, sources.context)} overrides ${key.path} in [remotes.${appliedRemote}].`, + ); + } + } + const fromConfig = origin.tier === "config" ? configOrigin : undefined; + const declared = declaredWrite(key, picked.success, raw); + if (declared !== undefined) { + setDocumentValue(declaredDraft, key.segments, declared); + declaredResolved.push(key.segments); + if (fromConfig !== undefined) declaredEnvOrigins.push(fromConfig); + } + const materialized = materializedWrite(key, picked.success, declared); + if (materialized !== undefined) { + setDocumentValue(materializedDraft, key.segments, materialized); + materializedResolved.push(key.segments); + if (fromConfig !== undefined) materializedEnvOrigins.push(fromConfig); + } + } + const [firstFailure] = entryFailures; + if (firstFailure !== undefined) { + const issues = entryFailures.flatMap((failure) => failure.issues ?? []); + return yield* new CliConfigValueError({ + path: firstFailure.path, + tier: "config", + message: decodingFailedMessage(issues), + issues, + ...(firstFailure.source === undefined ? {} : { source: firstFailure.source }), + }); + } + + const envValues: Record = Object.create(null); + for (const name of new Set([...Object.keys(projectEnv.values), ...shell.entries().keys()])) { + const value = lookupEnv(name); + if (value !== undefined) envValues[name] = value; + } + + const mergedForDecode = merged ?? emptyMergedDocument(target.workdir, path.sep); + const loaded = withEnvOrigins( + yield* withPlatform( + decodeMergedCliConfig(mergedForDecode, { + envValues, + cliCompat: true, + document: declaredDraft, + resolvedPaths: declaredResolved, + onWarning: warnDeprecationOnce, + }), + ), + declaredEnvOrigins, + ); + const fileDeclared = withPlatform( + decodeMergedCliConfig(mergedForDecode, { + envValues, + cliCompat: true, + document: fileDraft, + resolvedPaths: fileResolved, + onWarning: warnDeprecationOnce, + }), + ).pipe(Effect.map((decoded) => withEnvOrigins(decoded, fileEnvOrigins))); + const materializedLoaded = withEnvOrigins( + yield* withPlatform( + decodeMergedCliConfig(mergedForDecode, { + envValues, + cliCompat: true, + document: materializedDraft, + resolvedPaths: materializedResolved, + onWarning: warnDeprecationOnce, + }), + ), + materializedEnvOrigins, + ); + + const materialized: CliConfigMaterialized = { + config: materializedLoaded.config, + originAt: (configPath) => { + const known = origins.get(configPath); + if (known !== undefined) return known; + const decoded = materializedLoaded.valueOrigins?.find( + (candidate) => candidate.path.join(".") === configPath, + ); + return decoded === undefined + ? { tier: "default" } + : { + tier: "config", + origin: decoded, + ...(decoded.source === "remote" && appliedRemote !== undefined + ? { remote: appliedRemote } + : {}), + ...(merged === null ? {} : { file: merged.path }), + }; + }, + }; + + const get: ResolvedCliConfig["get"] = (key) => + Effect.gen(function* () { + const picked = pickCliConfigKey(key, sources); + if (Result.isFailure(picked)) return yield* picked.failure; + return picked.success; + }); + + for (const message of overrideWarnings) yield* warnOnce(message); + if (Option.isSome(debugLogger)) { + for (const [originPath, origin] of origins) { + if (origin.tier === "default") continue; + yield* debugLogger.value.debug( + `config: ${originPath} from ${describeCliConfigOrigin(origin, sources.context)}`, + ); + } + } + + return { + appliedRemote: Option.fromNullishOr(appliedRemote), + hasConfigFile: merged !== null, + get, + loaded, + fileDeclared, + materialized, + origins, + invalid, + familyNames, + declares: (configPath) => configAt(configPath) !== undefined, + withheldEnv, + dotenvPrivateKeys, + projectEnvValues: Object.assign, Record>( + Object.create(null), + Object.fromEntries(Object.entries(projectEnv.values).filter(([name]) => !withheld(name))), + ), + envValues: (names) => { + const wanted = [...new Set(names)]; + return shell.load(wanted).pipe( + Effect.map(() => { + const values: Record = Object.create(null); + for (const name of wanted) { + const value = lookupEnv(name); + if (value !== undefined) values[name] = value; + } + return values; + }), + ); + }, + } satisfies ResolvedCliConfig; + }); + + const cache = yield* Cache.makeWith(loadResolvedConfig, { + capacity: 32, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }); + + const invalidate = Effect.all([ + Cache.invalidateAll(cache), + Cache.invalidateAll(parsedDocuments), + ]).pipe(Effect.asVoid); + + return CliConfigValues.of({ + load: (target) => + Cache.get( + cache, + new LoadKey({ + workdir: target.workdir, + projectRef: target.projectRef, + ignoreConfigFile: target.ignoreConfigFile === true, + tolerateUnreadableLinkedRef: target.tolerateUnreadableLinkedRef === true, + tolerateInvalid: target.tolerateInvalid === true, + }), + ), + writeThrough: (write) => Effect.ensuring(write, invalidate), + invalidate, + }); + }), +); diff --git a/apps/cli/src/config/cli-config-values.service.ts b/apps/cli/src/config/cli-config-values.service.ts new file mode 100644 index 0000000000..bb17a0d189 --- /dev/null +++ b/apps/cli/src/config/cli-config-values.service.ts @@ -0,0 +1,106 @@ +import type { CliConfig, LoadedCliConfig } from "@supabase/config"; +import type { + decodeMergedCliConfig, + mergeParsedCliConfig, + parseCliConfigDocumentFile, +} from "@supabase/config/internal"; +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +import type { ProjectRefReadError } from "../shared/config/temp-paths.ts"; +import type { CliConfigLoadError } from "../shared/config/cli-config-load.errors.ts"; +import type { CliConfigFamilyId } from "./cli-config-key-annotations.ts"; +import type { CliConfigFlagDeclaration } from "./cli-config-flags.ts"; +import type { CliConfigKey, CliConfigKeyOrigin, CliConfigValue } from "./cli-config-key.ts"; +import type { CliConfigFlagConflictError, CliConfigValueError } from "./cli-config.errors.ts"; + +interface CliConfigLoadTarget { + readonly workdir: string; + /** The project the command targets, when it has one; it selects the `[remotes.*]` block. */ + readonly projectRef: Option.Option; + /** Resolves env and defaults only, as if the config file were absent. */ + readonly ignoreConfigFile?: true; + /** Treats an unreadable `.temp/project-ref` as an unlinked workdir instead of failing the load. */ + readonly tolerateUnreadableLinkedRef?: true; + /** Leaves an invalid value out of the resolved config and lists it in `invalid`, instead of failing the load. */ + readonly tolerateInvalid?: true; +} + +/** An env variable that held a value but was withheld because it belongs to the linked project. */ +export interface CliConfigWithheldEnv { + readonly path: string; + readonly envName: string; + readonly tier: "shell" | "projectEnv"; + readonly targetRef: string; + readonly linkedRef: string; +} + +export interface CliConfigMaterialized { + /** The declared config plus defaults and normalizers. */ + readonly config: CliConfig; + readonly originAt: (path: string) => CliConfigKeyOrigin; +} + +/** What `@supabase/config` can fail with while parsing, merging and decoding the document. */ +type CliConfigPackageError = + | Effect.Error> + | Effect.Error> + | Effect.Error>; + +/** Everything `load` can fail with; package errors keep their own tags. */ +type CliConfigLoadFailure = + | CliConfigPackageError + | CliConfigLoadError + | CliConfigValueError + | CliConfigFlagConflictError + | ProjectRefReadError; + +export interface ResolvedCliConfig { + readonly appliedRemote: Option.Option; + /** Whether a config file was found; without one `loaded` is decoded from the winners alone. */ + readonly hasConfigFile: boolean; + /** The winning value and its origin. */ + readonly get: ( + key: CliConfigKey, + ) => Effect.Effect, CliConfigValueError>; + /** What the project declares: the document with every flag, env and secret winner written in, before defaults. */ + readonly loaded: LoadedCliConfig; + /** What the config file alone declares: the matched `[remotes.*]` block over the base, `env()` resolved, no flag or `SUPABASE_*` overlay. */ + readonly fileDeclared: Effect.Effect< + LoadedCliConfig, + Effect.Error> + >; + /** `loaded` plus defaults and normalizers; the config commands act on. */ + readonly materialized: CliConfigMaterialized; + /** The origin of every key the registry resolved, by dotted path. */ + readonly origins: ReadonlyMap; + /** Values `tolerateInvalid` left out; always empty otherwise. */ + readonly invalid: ReadonlyArray; + /** The entry names of a family: those the registry declares plus those in the merged document. */ + readonly familyNames: (family: CliConfigFamilyId) => ReadonlyArray; + /** Whether the merged document holds a value or table at the dotted path. */ + readonly declares: (path: string) => boolean; + /** Env variables held for the linked project that this target's resolution ignored. */ + readonly withheldEnv: ReadonlyArray; + readonly dotenvPrivateKeys: ReadonlyArray; + /** Values from `supabase/.env*` files only; a name the shell sets is never in here. */ + readonly projectEnvValues: Readonly>; + /** The non-empty value of each name, shell before project `.env*`; unset and empty names are omitted. */ + readonly envValues: ( + names: Iterable, + ) => Effect.Effect, CliConfigLoadError>; +} + +interface CliConfigValuesShape { + readonly load: ( + target: CliConfigLoadTarget, + ) => Effect.Effect; + /** Runs a write to config or `.temp`, then drops the memoised resolved configs it may have staled. */ + readonly writeThrough: (write: Effect.Effect) => Effect.Effect; + /** Drops the memoised documents and resolved configs so the next `load` re-reads disk. */ + readonly invalidate: Effect.Effect; +} + +export class CliConfigValues extends Context.Service()( + "supabase/cli/CliConfigValues", +) {} diff --git a/apps/cli/src/config/cli-config.errors.ts b/apps/cli/src/config/cli-config.errors.ts new file mode 100644 index 0000000000..d828a84402 --- /dev/null +++ b/apps/cli/src/config/cli-config.errors.ts @@ -0,0 +1,38 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import type { CliConfigTier } from "./cli-config-key.ts"; + +/** + * A config key's winning value is invalid. `message` names the source that supplied it, `source` + * repeats that source on its own, and `envName` or `flag` names the offending variable or flag. + */ +export class CliConfigValueError extends Data.TaggedError("CliConfigValueError")<{ + readonly path: string; + readonly tier: CliConfigTier; + readonly message: string; + readonly source?: string; + readonly envName?: string; + readonly flag?: string; + /** Per-entry decode failures, merged when several keys fail in one load. */ + readonly issues?: ReadonlyArray; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** Two flags assigned different values to the same config key. */ +export class CliConfigFlagConflictError extends Data.TaggedError("CliConfigFlagConflictError")<{ + readonly path: string; + readonly flags: readonly [string, string]; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index d272603f90..09acba00b4 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -1,3 +1,4 @@ +import { findCliProjectRoot } from "@supabase/config/effect"; import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { lastExplicitLongFlagValue } from "../shared/cli/flag-groups.ts"; @@ -8,8 +9,9 @@ import { type LoadedProfile, type ProfileLoadError, } from "../command-internal/profile-load.ts"; -import { DebugLogger, type DebugLoggerShape } from "../command-internal/debug-logger.service.ts"; +import { DebugLogger, type DebugLoggerShape } from "../shared/output/debug-logger.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { CliEnvNames } from "./cli-config-keys.ts"; import { CommandSettings } from "./command-settings.service.ts"; import { resolveSupabaseHomeValue } from "../shared/config/supabase-home.ts"; @@ -68,14 +70,13 @@ function resolveProfile( * string (e.g. `.`), but downstream readers such as the project-id cwd-basename fallback need * a real directory name, not a relative fragment that would sanitize to an empty project id. * - * `explicit` is true only when the flag/env value was used verbatim, without walking up to find - * `supabase/config.toml`; some config loads use it to skip a redundant ancestor search. + * `explicit` is true only when the flag/env value was used verbatim. Otherwise the nearest + * ancestor holding `supabase/config.json` or `supabase/config.toml` wins, falling back to `cwd`. */ export const resolveWorkdir = Effect.fn("CommandSettings.resolveWorkdir")(function* ( flagValue: Option.Option, envValue: Option.Option, cwd: string, - configTomlExists: (path: string) => Effect.Effect, path: Path.Path, ) { if (Option.isSome(flagValue) && flagValue.value.length > 0) { @@ -84,18 +85,8 @@ export const resolveWorkdir = Effect.fn("CommandSettings.resolveWorkdir")(functi if (Option.isSome(envValue) && envValue.value.length > 0) { return { workdir: path.resolve(cwd, envValue.value), explicit: true }; } - let current = cwd; - while (true) { - const candidate = path.join(current, "supabase", "config.toml"); - if (yield* configTomlExists(candidate)) { - return { workdir: current, explicit: false }; - } - const parent = path.dirname(current); - if (parent === current) { - return { workdir: cwd, explicit: false }; - } - current = parent; - } + const projectRoot = yield* findCliProjectRoot(cwd); + return { workdir: projectRoot ?? cwd, explicit: false }; }); export const commandSettingsLayer = Layer.unwrap( @@ -144,11 +135,6 @@ export const commandSettingsLayer = Layer.unwrap( debugLogger, ); - const rawDbPassword = yield* read(Config.option(Config.String("SUPABASE_DB_PASSWORD"))); - const dbPassword = Option.filter(rawDbPassword, (value) => value.length > 0).pipe( - Option.map((value) => Redacted.make(value, { label: "SUPABASE_DB_PASSWORD" })), - ); - const rawGithubToken = yield* read(Config.option(Config.String("GITHUB_TOKEN"))); const githubToken = Option.filter(rawGithubToken, (value) => value.length > 0).pipe( Option.map((value) => Redacted.make(value, { label: "GITHUB_TOKEN" })), @@ -159,7 +145,7 @@ export const commandSettingsLayer = Layer.unwrap( Option.map((value) => Redacted.make(value, { label: "SUPABASE_ACCESS_TOKEN" })), ); - const rawProjectId = yield* read(Config.option(Config.String("SUPABASE_PROJECT_ID"))); + const rawProjectId = yield* read(Config.option(Config.String(CliEnvNames.projectId.name))); const projectId = Option.filter(rawProjectId, (value) => value.length > 0); const workdirEnvValue = yield* read(Config.option(Config.String("SUPABASE_WORKDIR"))); @@ -167,7 +153,6 @@ export const commandSettingsLayer = Layer.unwrap( workdirFlag, workdirEnvValue, runtimeInfo.cwd, - (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), path, ); @@ -187,7 +172,6 @@ export const commandSettingsLayer = Layer.unwrap( poolerHost, dashboardUrl, accessToken, - dbPassword, githubToken, projectId, workdir, diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index 1a49982be7..38d68bbd17 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -158,13 +158,9 @@ describe("commandSettingsLayer", () => { }).pipe(Effect.provide(makeLayer({ env: {}, cwd: tempRoot }))), ); - it.effect("captures SUPABASE_DB_PASSWORD and GITHUB_TOKEN as redacted options", () => + it.effect("captures GITHUB_TOKEN as a redacted option", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(Option.isSome(config.dbPassword)).toBe(true); - if (Option.isSome(config.dbPassword)) { - expect(Redacted.value(config.dbPassword.value)).toBe("db-pw"); - } expect(Option.isSome(config.githubToken)).toBe(true); if (Option.isSome(config.githubToken)) { expect(Redacted.value(config.githubToken.value)).toBe("gh-tok"); @@ -179,16 +175,11 @@ describe("commandSettingsLayer", () => { ), ); - it.effect("captures empty SUPABASE_DB_PASSWORD and GITHUB_TOKEN as none", () => + it.effect("captures an empty GITHUB_TOKEN as none", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(Option.isNone(config.dbPassword)).toBe(true); expect(Option.isNone(config.githubToken)).toBe(true); - }).pipe( - Effect.provide( - makeLayer({ env: { SUPABASE_DB_PASSWORD: "", GITHUB_TOKEN: "" }, cwd: tempRoot }), - ), - ), + }).pipe(Effect.provide(makeLayer({ env: { GITHUB_TOKEN: "" }, cwd: tempRoot }))), ); it.effect("preserves an empty SUPABASE_PROFILE without selecting a profile", () => @@ -503,8 +494,6 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.workdir).toBe("/flag/workdir"); - // An explicit --workdir is used verbatim, letting `shouldSearchAncestors` skip the - // second ancestor climb inside `loadCliConfig`. expect(config.explicitWorkdir).toBe(true); }).pipe( Effect.provide( @@ -597,6 +586,51 @@ describe("commandSettingsLayer", () => { }).pipe(Effect.provide(makeLayer({ cwd: nested }))); }); + it.effect("walks up from CWD to a project that only has supabase/config.json", () => { + const projectRoot = join(tempRoot, "json-project"); + const nested = join(projectRoot, "apps", "web"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(projectRoot, "supabase", "config.json"), '{"project_id":"x"}\n'); + + return Effect.gen(function* () { + const config = yield* CommandSettings; + expect(config.workdir).toBe(projectRoot); + expect(config.explicitWorkdir).toBe(false); + }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }); + + it.effect("uses an explicit --workdir below a config.json project verbatim", () => { + const projectRoot = join(tempRoot, "json-project"); + const below = join(projectRoot, "apps", "web"); + mkdirSync(join(projectRoot, "supabase"), { recursive: true }); + mkdirSync(below, { recursive: true }); + writeFileSync(join(projectRoot, "supabase", "config.json"), '{"project_id":"x"}\n'); + + return Effect.gen(function* () { + const config = yield* CommandSettings; + expect(config.workdir).toBe(below); + expect(config.explicitWorkdir).toBe(true); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some(below), cwd: projectRoot }))); + }); + + it.effect("picks the directory holding both config files over an enclosing project", () => { + const outer = join(tempRoot, "outer"); + const inner = join(outer, "packages", "inner"); + const nested = join(inner, "src"); + mkdirSync(join(outer, "supabase"), { recursive: true }); + mkdirSync(join(inner, "supabase"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(outer, "supabase", "config.json"), '{"project_id":"outer"}\n'); + writeFileSync(join(inner, "supabase", "config.json"), '{"project_id":"inner"}\n'); + writeFileSync(join(inner, "supabase", "config.toml"), 'project_id = "inner"\n'); + + return Effect.gen(function* () { + const config = yield* CommandSettings; + expect(config.workdir).toBe(inner); + }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }); + it.effect("falls back to CWD when no supabase/config.toml found", () => Effect.gen(function* () { const config = yield* CommandSettings; diff --git a/apps/cli/src/config/command-settings.service.ts b/apps/cli/src/config/command-settings.service.ts index 3c2b57143a..58b702e824 100644 --- a/apps/cli/src/config/command-settings.service.ts +++ b/apps/cli/src/config/command-settings.service.ts @@ -32,16 +32,13 @@ interface CommandSettingsShape { /** Dashboard base URL for the active profile, used by the connect-failure network-restrictions hint. */ readonly dashboardUrl: string; readonly accessToken: Option.Option>; - /** `SUPABASE_DB_PASSWORD` captured at settings resolution; empty captures as none. */ - readonly dbPassword: Option.Option>; /** Ambient `GITHUB_TOKEN`; raises anonymous GitHub API rate limits. Empty captures as none. */ readonly githubToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; /** * Whether {@link workdir} came from an explicit `--workdir`/`SUPABASE_WORKDIR` rather than - * the default ancestor walk-up. Config loads use it to decide whether to search ancestor - * directories; see `shouldSearchAncestors` in `command-internal/workdir-search.ts`. + * the default walk-up to the nearest `supabase/config.{json,toml}`. */ readonly explicitWorkdir: boolean; /** Raw `SUPABASE_WORKDIR` value; `Some("")` differs from an absent variable and is used verbatim. */ diff --git a/apps/cli/src/config/profile-file.ts b/apps/cli/src/config/profile-file.ts index 20a0c7187c..c46dad0ee6 100644 --- a/apps/cli/src/config/profile-file.ts +++ b/apps/cli/src/config/profile-file.ts @@ -1,5 +1,5 @@ import { Data, Effect, FileSystem, Path } from "effect"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; +import { readSupabaseHome } from "../shared/config/supabase-home.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -24,13 +24,8 @@ export class ProfileSaveError extends Data.TaggedError("ProfileSaveError")<{ } } -export function profileFilePath( - path: Path.Path, - homeDir: string, - env?: Readonly>, -): string { - return path.join(resolveSupabaseHome(path, env ?? process.env, homeDir), "profile"); -} +export const profileFilePath = (path: Path.Path, homeDir: string) => + Effect.map(readSupabaseHome(path, homeDir), (home) => path.join(home, "profile")); /** Writes the profile name to the resolved profile path. Fatal on failure. */ export const saveProfileName = ( diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 003c75494b..3088625c2e 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; -import { readProjectRefFile } from "../command-internal/temp-paths.ts"; +import { readProjectRefFile } from "../shared/config/temp-paths.ts"; import { CommandSettings } from "./command-settings.service.ts"; import { InvalidProjectRefError, diff --git a/apps/cli/src/config/project-ref.layer.unit.test.ts b/apps/cli/src/config/project-ref.layer.unit.test.ts index a13bef1632..e9535d3c48 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -20,7 +20,6 @@ const ANOTHER_REF = "qrstuvwxyzabcdefghij"; function mockCliSettings(opts: { workdir: string; projectId?: string }) { return Layer.succeed(CommandSettings, { - dbPassword: Option.none(), githubToken: Option.none(), workdirEnvValue: Option.none(), profile: "supabase", diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index 2cc5646234..5e35d145d5 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -2,7 +2,7 @@ import type { Effect, Option } from "effect"; import { Context } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../command-internal/ref-patterns.ts"; -import type { ProjectRefReadError } from "../command-internal/temp-paths.ts"; +import type { ProjectRefReadError } from "../shared/config/temp-paths.ts"; import type { InvalidProjectRefError, ProjectRefNotLinkedError, diff --git a/apps/cli/src/config/testdata/env-overridable-keys.json b/apps/cli/src/config/testdata/env-overridable-keys.json new file mode 100644 index 0000000000..318416a5a0 --- /dev/null +++ b/apps/cli/src/config/testdata/env-overridable-keys.json @@ -0,0 +1,171 @@ +[ + "project_id", + "api.schemas", + "db.port", + "db.shadow_port", + "db.major_version", + "db.migrations.enabled", + "db.migrations.schema_paths", + "db.seed.enabled", + "db.seed.sql_paths", + "auth.enabled", + "api.enabled", + "edge_runtime.deno_version", + "experimental.webhooks.enabled", + "experimental.pgdelta.enabled", + "experimental.pgdelta.declarative_schema_path", + "experimental.pgdelta.format_options", + "api.auto_expose_new_tables", + "analytics.enabled", + "analytics.backend", + "analytics.gcp_project_id", + "analytics.gcp_project_number", + "analytics.gcp_jwt_path", + "db.orioledb_version", + "experimental.s3_host", + "experimental.s3_region", + "experimental.s3_access_key", + "experimental.s3_secret_key", + "realtime.enabled", + "realtime.ip_version", + "realtime.max_header_length", + "storage.enabled", + "storage.file_size_limit", + "db.health_timeout", + "db.settings.effective_cache_size", + "db.settings.logical_decoding_work_mem", + "db.settings.maintenance_work_mem", + "db.settings.max_connections", + "db.settings.max_locks_per_transaction", + "db.settings.max_parallel_maintenance_workers", + "db.settings.max_parallel_workers", + "db.settings.max_parallel_workers_per_gather", + "db.settings.max_replication_slots", + "db.settings.max_slot_wal_keep_size", + "db.settings.max_standby_archive_delay", + "db.settings.max_standby_streaming_delay", + "db.settings.max_wal_size", + "db.settings.max_wal_senders", + "db.settings.max_worker_processes", + "db.settings.session_replication_role", + "db.settings.shared_buffers", + "db.settings.statement_timeout", + "db.settings.track_activity_query_size", + "db.settings.track_commit_timestamp", + "db.settings.wal_keep_size", + "db.settings.wal_sender_timeout", + "db.settings.work_mem", + "db.network_restrictions.enabled", + "db.root_key", + "api.port", + "api.tls.enabled", + "api.tls.cert_path", + "api.tls.key_path", + "api.external_url", + "auth.jwt_secret", + "auth.jwt_expiry", + "auth.site_url", + "auth.anon_key", + "auth.service_role_key", + "auth.signing_keys_path", + "auth.external_url", + "auth.third_party.firebase.enabled", + "auth.third_party.firebase.project_id", + "auth.third_party.auth0.enabled", + "auth.third_party.auth0.tenant", + "auth.third_party.auth0.tenant_region", + "auth.third_party.aws_cognito.enabled", + "auth.third_party.aws_cognito.user_pool_id", + "auth.third_party.aws_cognito.user_pool_region", + "auth.third_party.clerk.enabled", + "auth.third_party.clerk.domain", + "auth.third_party.workos.enabled", + "auth.third_party.workos.issuer_url", + "auth.jwt_issuer", + "auth.additional_redirect_urls", + "studio.enabled", + "studio.port", + "local_smtp.enabled", + "local_smtp.port", + "auth.enable_signup", + "auth.enable_anonymous_sign_ins", + "auth.enable_refresh_token_rotation", + "auth.refresh_token_reuse_interval", + "auth.enable_manual_linking", + "auth.minimum_password_length", + "auth.password_requirements", + "auth.passkey.enabled", + "auth.webauthn.rp_id", + "auth.webauthn.rp_origins", + "auth.hook.mfa_verification_attempt.enabled", + "auth.hook.mfa_verification_attempt.uri", + "auth.hook.mfa_verification_attempt.secrets", + "auth.hook.password_verification_attempt.enabled", + "auth.hook.password_verification_attempt.uri", + "auth.hook.password_verification_attempt.secrets", + "auth.hook.custom_access_token.enabled", + "auth.hook.custom_access_token.uri", + "auth.hook.custom_access_token.secrets", + "auth.hook.send_sms.enabled", + "auth.hook.send_sms.uri", + "auth.hook.send_sms.secrets", + "auth.hook.send_email.enabled", + "auth.hook.send_email.uri", + "auth.hook.send_email.secrets", + "auth.hook.before_user_created.enabled", + "auth.hook.before_user_created.uri", + "auth.hook.before_user_created.secrets", + "auth.mfa.totp.enroll_enabled", + "auth.mfa.totp.verify_enabled", + "auth.mfa.phone.enroll_enabled", + "auth.mfa.phone.verify_enabled", + "auth.mfa.phone.otp_length", + "auth.mfa.web_authn.enroll_enabled", + "auth.mfa.web_authn.verify_enabled", + "auth.mfa.max_enrolled_factors", + "auth.mfa.phone.template", + "auth.mfa.phone.max_frequency", + "auth.captcha.enabled", + "auth.captcha.provider", + "auth.captcha.secret", + "auth.email.smtp.enabled", + "auth.email.smtp.port", + "auth.email.smtp.pass", + "auth.email.smtp.host", + "auth.email.smtp.user", + "auth.email.smtp.admin_email", + "auth.email.smtp.sender_name", + "auth.email.enable_signup", + "auth.email.double_confirm_changes", + "auth.email.enable_confirmations", + "auth.email.secure_password_change", + "auth.email.otp_length", + "auth.email.otp_expiry", + "auth.email.max_frequency", + "auth.sms.enable_signup", + "auth.sms.enable_confirmations", + "auth.sms.twilio.enabled", + "auth.sms.twilio.auth_token", + "auth.sms.twilio_verify.enabled", + "auth.sms.twilio_verify.auth_token", + "auth.sms.messagebird.enabled", + "auth.sms.messagebird.access_key", + "auth.sms.textlocal.enabled", + "auth.sms.textlocal.api_key", + "auth.sms.vonage.enabled", + "auth.sms.vonage.api_secret", + "auth.sms.twilio.account_sid", + "auth.sms.twilio.message_service_sid", + "auth.sms.twilio_verify.account_sid", + "auth.sms.twilio_verify.message_service_sid", + "auth.sms.messagebird.originator", + "auth.sms.textlocal.sender", + "auth.sms.vonage.from", + "auth.sms.vonage.api_key", + "auth.sms.template", + "auth.sms.max_frequency", + "auth.publishable_key", + "auth.secret_key", + "studio.openai_api_key", + "studio.api_url" +] diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index 73cc56ab02..4fe786976c 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -181,10 +181,7 @@ describe("buildDocsSpec", () => { expect(link).toBeDefined(); expect(link?.tags).toEqual(["local-dev"]); expect(link?.usage).toBe("supabase link [ref-or-branch] [flags]"); - expect(link?.flags.map((flag) => flag.id)).toEqual(["password", "project-ref", "skip-pooler"]); - expect(link?.flags.find((flag) => flag.id === "password")?.name).toBe( - "-p, --password ", - ); + expect(link?.flags.map((flag) => flag.id)).toEqual(["project-ref", "skip-pooler"]); expect(link?.description).toBe( docsStripOverlayHeading(content.overlays.get("supabase/link.md") ?? ""), ); diff --git a/apps/cli/src/shared/cli/code-structure.unit.test.ts b/apps/cli/src/shared/cli/code-structure.unit.test.ts index d01ff65061..1dafe538c0 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -1,42 +1,14 @@ import { BunServices } from "@effect/platform-bun"; -import { expect, layer } from "@effect/vitest"; +import { describe, expect, it, layer } from "@effect/vitest"; import { Effect, FileSystem, Path, PlatformError } from "effect"; import { fileURLToPath } from "node:url"; -const cliConfigLoadFile = "command-internal/cli-config-load.ts"; -const strictPublicLoaderAllowList = new Set(["commands/experimental/compute/compute.shared.ts"]); - -const compatOption = ["cli", "Compat"].join(""); -const internalLoaderNames = - /\b(?:loadCliConfig|resolveCliConfigValue|resolveCliConfigSubtree|decodeCliConfigDocumentForValidationEffect)\b/; -const publicLoaderNames = /\b(?:loadCliConfig|resolveCliConfigValue|resolveCliConfigSubtree)\b/; -const namedConfigImport = - /\b(?:import|export)\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'](@supabase\/config(?:\/effect|\/internal)?)["']/g; -const wholeConfigImport = - /\b(?:(?:import|export)\s+(?:type\s+)?\*\s*(?:as\s+\w+\s*)?from\s*|import\s*\(\s*)["'](@supabase\/config(?:\/effect|\/internal)?)["']/g; - -const findConfigLoadingViolations = (relativeFile: string, source: string): Array => { - const violations: Array = []; - if (source.includes(compatOption)) { - violations.push(`${relativeFile} mentions ${compatOption}`); - } - for (const [, specifier] of source.matchAll(wholeConfigImport)) { - violations.push( - `${relativeFile} uses a namespace, star, or dynamic import of ${specifier}; import named symbols instead`, - ); - } - for (const match of source.matchAll(namedConfigImport)) { - const [statement, names, specifier] = [match[0], match[1]!, match[2]!]; - const isInternal = specifier === "@supabase/config/internal"; - if (isInternal ? internalLoaderNames.test(names) : publicLoaderNames.test(names)) { - if (!isInternal && strictPublicLoaderAllowList.has(relativeFile)) continue; - violations.push( - `${relativeFile} imports a config loader from ${specifier} (\`${statement.replace(/\s+/g, " ")}\`); use ${cliConfigLoadFile}`, - ); - } - } - return violations; -}; +import { CLI_CONFIG_FLAGS } from "../../config/cli-config-key-annotations.ts"; +import { + CliEnvNames, + cliConfigRegistry, + isCliConfigEnvName, +} from "../../config/cli-config-keys.ts"; const srcDir = fileURLToPath(new URL("../..", import.meta.url)); @@ -100,6 +72,170 @@ function isSourceFile(filePath: string): boolean { ); } +const CONFIG_FOUNDATION_FILE = /^(?:config|shared\/config)\/cli-config-[^/]*\.ts$/; + +const registryEnvNames: ReadonlySet = new Set([ + ...cliConfigRegistry.keys.flatMap((key) => key.env), + ...Object.values(CliEnvNames).map((entry) => entry.name), +]); + +const isRegistryEnvName = (name: string): boolean => + registryEnvNames.has(name) || + isCliConfigEnvName(name) || + /^SUPABASE_REMOTES_[A-Z0-9_]+_PROJECT_ID$/.test(name); + +const stripComments = (source: string): string => + source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); + +const NAME_LITERAL = /(["'`])(SUPABASE_[A-Z0-9_]+)\1/g; +const NAME_PROPERTY = /\.(SUPABASE_[A-Z0-9_]+)\b/g; +const NAME_DESTRUCTURE = /\{[^{}]*?\b(SUPABASE_[A-Z0-9_]+)\b[^{}]*\}\s*=[^=>]/g; +const DYNAMIC_SUPABASE_READS: ReadonlyArray = [ + /\[\s*`SUPABASE_\$\{/g, + /\b(?:Config\.\w+|envOption|envValue|envOrDefault)\(\s*`SUPABASE_\$\{/g, +]; + +/** Registry env names written as a literal, property or destructured binding, whatever reads them. */ +export function findRegistryEnvReads(source: string): Array { + const code = stripComments(source); + const hits: Array = []; + for (const [pattern, group] of [ + [NAME_LITERAL, 2], + [NAME_PROPERTY, 1], + [NAME_DESTRUCTURE, 1], + ] as const) { + for (const match of code.matchAll(pattern)) { + const name = match[group]; + if (name !== undefined && isRegistryEnvName(name)) hits.push(name); + } + } + for (const pattern of DYNAMIC_SUPABASE_READS) { + for (const match of code.matchAll(pattern)) hits.push(match[0]); + } + return hits; +} + +const AMBIENT_ENV_ESCAPES: ReadonlyArray = [ + /\b(?:process|Bun)\s*\.\s*env\b/g, + /\b(?:process|Bun)\s*\[\s*["'`]env["'`]\s*\]/g, + /\b(?:globalThis|global)\s*(?:\.\s*(?:process|Bun)\b|\[\s*["'`](?:process|Bun)["'`]\s*\])/g, + /\{[^{}]*\benv\b[^{}]*\}\s*=\s*(?:globalThis\.)?(?:process|Bun)\b/g, + /=\s*(?:globalThis\.)?(?:process|Bun)\s*[;,)\n]/g, + /import\s+(?!process\b)\w+\s*(?:,[^;]*)?from\s*["'](?:node:)?process["']/g, + /import\s*\*\s*as\s*\w+\s*from\s*["'](?:node:)?process["']/g, + /import\s*\{[^}]*\benv\b[^}]*\}\s*from\s*["'](?:node:process|process|bun)["']/g, + /\brequire\(\s*["'](?:node:)?process["']\s*\)/g, +]; + +/** Ways to reach the process environment other than `CliConfigValues`. */ +export function findAmbientEnvEscapes(source: string): Array { + const code = stripComments(source); + return AMBIENT_ENV_ESCAPES.flatMap((pattern) => Array.from(code.matchAll(pattern), (m) => m[0])); +} + +/** Files that name a registry env variable without reading it, with the reason. */ +const REGISTRY_NAME_EXEMPT: Readonly> = { + "shared/telemetry/event-catalog.ts": "lists the variables whose presence the telemetry reports", +}; + +const AMBIENT_ENV_EXEMPT: ReadonlyArray = [ + /^shared\/cli\/bin\.ts$/, + /^shared\/compute\/stacks\//, +]; + +const BANNED_IDENTIFIERS: ReadonlyArray = [ + ...[ + ["remote", "Wins"], + ["make", "Remote", "Wins"], + ["Remote", "Overridable", "Key"], + ["remote", "Override", "Keys"], + ["env", "Override"], + ].map((parts) => new RegExp(String.raw`\b${parts.join("")}\w*`, "g")), + /\bloadProjectEnv\b/g, +]; + +export function findBannedIdentifiers(source: string): Array { + return BANNED_IDENTIFIERS.flatMap((pattern) => Array.from(source.matchAll(pattern), (m) => m[0])); +} + +const FLAG_INPUTS_CONSTRUCTION = + /(?:Layer\.\w+|Effect\.provideService|Context\.\w+|Command\.provide\w*)\(\s*CliConfigFlagInputs\b|\bCliConfigFlagInputs\.of\(/g; + +export function findFlagInputsConstruction(source: string): Array { + return Array.from(source.matchAll(FLAG_INPUTS_CONSTRUCTION), (m) => m[0]); +} + +const declaredConfigFlagNames: ReadonlyArray = Object.values(CLI_CONFIG_FLAGS).flatMap( + (declaration) => declaration.names, +); + +export function findRawConfigFlags(source: string): Array { + return declaredConfigFlagNames.flatMap((name) => + Array.from( + source.matchAll(new RegExp(String.raw`\bFlag\.\w+\(\s*["']${name}["']`, "g")), + (m) => m[0], + ), + ); +} + +const CONFIG_LOADERS = [ + "loadCliConfig", + "resolveCliConfigValue", + "resolveCliConfigSubtree", + "decodeCliConfigDocumentForValidationEffect", + "loadCliProjectEnvironment", +]; + +const COMPAT_OPTION = ["cli", "Compat"].join(""); +const WHOLE_CONFIG_IMPORT = + /\b(?:(?:import|export)\s+(?:type\s+)?\*\s*(?:as\s+\w+\s*)?from\s*|import\s*\(\s*)["']@supabase\/config(?:\/effect|\/internal)?["']/g; + +/** Imports of the package loaders that resolve config outside the `CliConfigValues` resolved config. */ +export function findConfigLoaderImports( + source: string, + loaders: ReadonlyArray = CONFIG_LOADERS, +): Array { + const pattern = new RegExp( + String.raw`import\s+(?:type\s+)?\{[^}]*\b(?:${loaders.join("|")})\b[^}]*\}\s*from\s*["']@supabase/config[^"']*["']`, + "g", + ); + return Array.from(stripComments(source).matchAll(pattern), (m) => m[0]); +} + +/** Every way to reach the package loaders: named imports, whole-module imports, or the compat option. */ +function findConfigLoaderBypasses(source: string): Array { + const code = stripComments(source); + return [ + ...findConfigLoaderImports(source), + ...Array.from(code.matchAll(WHOLE_CONFIG_IMPORT), (m) => m[0]), + ...(code.includes(COMPAT_OPTION) ? [COMPAT_OPTION] : []), + ]; +} + +const allSpecifiers = (source: string): Array => + Array.from( + source.matchAll(/(?:from\s+|import\(\s*)["']([^"']+)["']/g), + (match) => match[1]!, + ).filter((specifier) => specifier.startsWith(".")); + +const isAnyTypeScript = (filePath: string): boolean => filePath.endsWith(".ts"); + +const scanSource = ( + files: ReadonlyArray, + rule: (relativePath: string, source: string) => ReadonlyArray, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const violations: Array = []; + for (const filePath of files) { + const relativePath = path.relative(srcDir, filePath).split(path.sep).join("/"); + const source = yield* fs.readFileString(filePath); + for (const hit of rule(relativePath, source)) violations.push(`${relativePath}: ${hit}`); + } + return violations; + }); + layer(BunServices.layer)("code structure", (it) => { it.effect("does not keep barrel index.ts files under src", () => Effect.gen(function* () { @@ -175,21 +311,220 @@ layer(BunServices.layer)("code structure", (it) => { }), ); - it.effect("routes CLI config loading through the single cli-config-load wrapper", () => + it.effect("keeps registry env names behind the config foundation", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { path } = yield* layout; - const violations: Array = []; + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + CONFIG_FOUNDATION_FILE.test(relativePath) || relativePath in REGISTRY_NAME_EXEMPT + ? [] + : findRegistryEnvReads(source), + ); + expect(violations).toEqual([]); + }), + ); - for (const filePath of (yield* walk(srcDir)).filter((file) => file.endsWith(".ts"))) { - const relativeFile = path.relative(srcDir, filePath).split(path.sep).join("/"); - if (relativeFile === cliConfigLoadFile) continue; - violations.push( - ...findConfigLoadingViolations(relativeFile, yield* fs.readFileString(filePath)), - ); - } + it.effect("keeps process environment access behind the config foundation", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + CONFIG_FOUNDATION_FILE.test(relativePath) || + AMBIENT_ENV_EXEMPT.some((exempt) => exempt.test(relativePath)) + ? [] + : findAmbientEnvEscapes(source), + ); + expect(violations).toEqual([]); + }), + ); + it.effect("bans the legacy overlay identifiers", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isAnyTypeScript); + const violations = yield* scanSource(files, (_, source) => findBannedIdentifiers(source)); + expect(violations).toEqual([]); + }), + ); + + it.effect("keeps the config foundation independent from commands and command-internal", () => + Effect.gen(function* () { + const { path, commandsDir } = yield* layout; + const commandInternalDir = path.join(srcDir, "command-internal"); + const files = (yield* walk(srcDir)).filter( + (filePath) => + isSourceFile(filePath) && + CONFIG_FOUNDATION_FILE.test(path.relative(srcDir, filePath).split(path.sep).join("/")), + ); + const violations = yield* scanSource(files, (relativePath, source) => + allSpecifiers(source).filter((specifier) => { + const resolved = resolveImport(path, path.join(srcDir, relativePath), specifier); + return resolved.startsWith(commandsDir) || resolved.startsWith(commandInternalDir); + }), + ); + expect(violations).toEqual([]); + }), + ); + + it.effect("constructs CliConfigFlagInputs only in cli-config-flags.ts", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + relativePath === "config/cli-config-flags.ts" ? [] : findFlagInputsConstruction(source), + ); expect(violations).toEqual([]); }), ); + + it.effect("declares registry-backed flags with key.flag", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (_, source) => findRawConfigFlags(source)); + expect(violations).toEqual([]); + }), + ); + + it.effect("imports the package config loaders only in the config foundation", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + CONFIG_FOUNDATION_FILE.test(relativePath) ? [] : findConfigLoaderBypasses(source), + ); + expect(violations).toEqual([]); + }), + ); + + it.effect("never imports loadCliProjectEnvironment, even in tests", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isAnyTypeScript); + const violations = yield* scanSource(files, (_, source) => + findConfigLoaderImports(source, ["loadCliProjectEnvironment"]), + ); + expect(violations).toEqual([]); + }), + ); +}); + +describe("config precedence guard rules", () => { + it("flags registry env names in every read position", () => { + const fixture = [ + 'process.env["SUPABASE_DB_PASSWORD"]', + "process.env.SUPABASE_API_PORT", + 'Bun.env["SUPABASE_PROJECT_ID"]', + 'Config.String("SUPABASE_DB_PORT")', + 'envOption("SUPABASE_AUTH_SITE_URL")', + 'envValue("SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET")', + 'ambientEnvironment()["SUPABASE_REMOTES_STAGING_PROJECT_ID"]', + "ambientEnvironment().SUPABASE_DB_PORT", + 'projectEnv["SUPABASE_DB_PORT"]', + 'supabaseEnvBool("SUPABASE_DB_SEED_ENABLED")', + 'supabaseEnvStringWithProjectFallback("SUPABASE_DB_PORT", env)', + 'resolvedConfig.sources.shell("SUPABASE_DB_PORT")', + 'lookupCliConfigEnv(resolvedConfig.sources, "SUPABASE_DB_PORT")', + 'values["SUPABASE_DB_PORT"]', + 'toml.projectEnv["SUPABASE_DB_PORT"]', + "const { SUPABASE_DB_PORT } = env;", + "process.env[`SUPABASE_${name}`]", + "envValue(`SUPABASE_${name}`)", + ].join("\n"); + expect(findRegistryEnvReads(fixture)).toHaveLength(18); + }); + + it("ignores names outside the registry, comments and prose", () => { + const fixture = [ + 'process.env["SUPABASE_ACCESS_TOKEN"];', + 'const label = "SUPABASE_DB_PORT is set"; `SUPABASE_${x}_KEY`;', + '// values["SUPABASE_DB_PORT"]', + "/* process.env.SUPABASE_DB_PORT */", + ].join("\n"); + expect(findRegistryEnvReads(fixture)).toEqual([]); + }); + + it("flags every way to reach the process environment", () => { + const fixture = [ + 'import { env } from "node:process";', + 'import * as proc from "node:process";', + 'import alias from "node:process";', + 'import { env as bunEnv } from "bun";', + "const { env: e2 } = process;", + 'const e3 = globalThis.process.env["X"];', + 'const e4 = process["env"]["X"];', + "const e5 = process.env.X;", + "const e6 = Bun.env.X;", + "const p = process;", + 'const e7 = require("node:process");', + ]; + for (const line of fixture) expect(findAmbientEnvEscapes(line), line).not.toEqual([]); + }); + + it("allows ordinary process use", () => { + const fixture = [ + 'import process from "node:process";', + "process.stderr.write(text); process.exit(1); const cwd = process.cwd();", + "// process.env.X", + ].join("\n"); + expect(findAmbientEnvEscapes(fixture)).toEqual([]); + }); + + it("flags each banned identifier", () => { + const names = [ + ["remote", "Wins"], + ["make", "Remote", "Wins"], + ["remote", "Override", "Keys"], + ["env", "Override"], + ["env", "Override", "Foo"], + ["load", "Project", "Env"], + ].map((parts) => parts.join("")); + for (const name of names) { + expect(findBannedIdentifiers(`const x = ${name}(y);`)).toEqual([name]); + } + }); + + it("does not flag the supported env loader", () => { + expect(findBannedIdentifiers("loadProjectEnvValues(fs, path, workdir)")).toEqual([]); + }); + + it("flags CliConfigFlagInputs construction", () => { + expect( + findFlagInputsConstruction("Layer.succeed(CliConfigFlagInputs, new Map())"), + ).toHaveLength(1); + expect(findFlagInputsConstruction("CliConfigFlagInputs.of(new Map())")).toHaveLength(1); + expect( + findFlagInputsConstruction("Effect.provideService(CliConfigFlagInputs, m)"), + ).toHaveLength(1); + expect(findFlagInputsConstruction("const inputs = yield* CliConfigFlagInputs;")).toEqual([]); + }); + + it("flags raw declarations of registry-backed flags", () => { + for (const name of declaredConfigFlagNames) { + expect(findRawConfigFlags(`Flag.String("${name}")`)).toHaveLength(1); + } + expect(findRawConfigFlags('Flag.String("project-id")')).toEqual([]); + }); + + it("flags package config loader imports", () => { + for (const loader of CONFIG_LOADERS) { + expect(findConfigLoaderImports(`import { ${loader} } from "@supabase/config";`)).toHaveLength( + 1, + ); + expect( + findConfigLoaderImports( + `import {\n type X,\n ${loader},\n} from "@supabase/config/internal";`, + ), + ).toHaveLength(1); + } + expect(findConfigLoaderImports('import { other } from "@supabase/config";')).toEqual([]); + }); + + it("flags whole-module imports of the config package and the compat option", () => { + expect( + findConfigLoaderBypasses('import * as config from "@supabase/config/internal";'), + ).toHaveLength(1); + expect(findConfigLoaderBypasses('const c = await import("@supabase/config");')).toHaveLength(1); + expect(findConfigLoaderBypasses(`load(cwd, { ${COMPAT_OPTION}: true });`)).toHaveLength(1); + expect(findConfigLoaderBypasses('import { other } from "@supabase/config";')).toEqual([]); + }); + + it("resolves static and dynamic relative specifiers", () => { + expect( + allSpecifiers('import a from "../command-internal/x.ts"; await import("./y.ts");'), + ).toEqual(["../command-internal/x.ts", "./y.ts"]); + }); }); diff --git a/apps/cli/src/shared/config/cli-config-env.ts b/apps/cli/src/shared/config/cli-config-env.ts new file mode 100644 index 0000000000..6557d729bd --- /dev/null +++ b/apps/cli/src/shared/config/cli-config-env.ts @@ -0,0 +1,159 @@ +import { ConfigProvider, Effect, FileSystem, Option, Path, type PlatformError } from "effect"; + +import { parseDotEnv } from "./dotenv.ts"; +import { CliConfigLoadError } from "./cli-config-load.errors.ts"; +import { CliEnvNameSource } from "./cli-config-provider.layer.ts"; + +const DEFAULT_SUPABASE_ENV = "development"; + +/** The ambient shell environment: only the names a caller asked for, loaded straight from the provider. */ +export interface CliShellEnvironment { + /** `undefined` when unset; a set-but-empty variable is `""` only if the provider preserves it. */ + readonly get: (name: string) => string | undefined; + /** The set variables among the names loaded so far. */ + readonly entries: () => ReadonlyMap; + /** Loads each name straight from the provider; later lookups see it through `get`. */ + readonly load: (names: Iterable) => Effect.Effect; +} + +/** + * Reads the ambient `ConfigProvider` by name. The provider is never enumerated, so lookup-only + * providers and `orElse` fallbacks behave the same as a plain environment. + */ +export const readShellEnvironment = Effect.fn("CliConfigEnv.readShell")(function* (options?: { + readonly names?: Iterable; + /** Loads every variable named `` or `_*` that the environment name source lists. */ + readonly namesWithPrefix?: Iterable; +}) { + const provider = yield* ConfigProvider.ConfigProvider; + const variables = new Map(); + const attempted = new Set(); + const listNames = yield* CliEnvNameSource; + + const loadPath = (path: ReadonlyArray) => + provider + .load(path) + .pipe( + Effect.mapError( + () => new CliConfigLoadError({ message: "failed to resolve environment variables" }), + ), + ); + + const load = (names: Iterable) => + Effect.forEach( + names, + (name) => { + if (attempted.has(name)) return Effect.void; + attempted.add(name); + return loadPath([name]).pipe( + Effect.map((node) => { + if (node?.value !== undefined) variables.set(name, node.value); + }), + ); + }, + { discard: true }, + ); + + yield* load(options?.names ?? []); + const prefixes = [...(options?.namesWithPrefix ?? [])]; + if (prefixes.length > 0) { + const listed = [...listNames()]; + yield* load( + listed.filter((name) => + prefixes.some((prefix) => name === prefix || name.startsWith(`${prefix}_`)), + ), + ); + } + + return { + get: (name) => variables.get(name), + entries: () => variables, + load, + } satisfies CliShellEnvironment; +}); + +/** The dotenv filenames for a `SUPABASE_ENV` value, highest precedence first. */ +export const candidateDotenvFilenames = (env: string): ReadonlyArray => [ + `.env.${env}.local`, + ...(env === "test" ? [] : [".env.local"]), + `.env.${env}`, + ".env", +]; + +const ABSENT_FILE_CODES: ReadonlySet = new Set(["ELOOP", "ENOTDIR"]); + +/** A missing file, a symlink loop, or a path through a non-directory: all read as "no such file". */ +const isAbsentFileReason = (reason: PlatformError.PlatformError["reason"]): boolean => { + if (reason._tag === "NotFound") return true; + if (reason._tag !== "BadResource") return false; + const { cause } = reason; + return typeof cause === "object" && cause !== null && "code" in cause + ? ABSENT_FILE_CODES.has(cause.code) + : false; +}; + +interface CliProjectEnvFiles { + readonly values: Readonly>; + /** The absolute path of the file each value came from. */ + readonly files: Readonly>; +} + +/** + * Loads the project `.env*` files without touching the process environment. Files are read in + * `SUPABASE_ENV` order under `supabase/` and then the workdir; the first writer of a key wins, and a + * key the shell already sets, even to the empty string, is never taken from a file. + */ +export const loadCliProjectEnvFiles = Effect.fn("CliConfigEnv.load")(function* ( + workdir: string, + options?: { readonly shell?: CliShellEnvironment }, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const shell = options?.shell ?? (yield* readShellEnvironment()); + + yield* shell.load(["SUPABASE_ENV"]); + const selected = Option.filter( + Option.fromNullishOr(shell.get("SUPABASE_ENV")), + (value) => value.length > 0, + ); + const env = Option.getOrElse(selected, () => DEFAULT_SUPABASE_ENV); + const filenames = [...new Set(candidateDotenvFilenames(env))]; + + const values: Record = Object.create(null); + const files: Record = Object.create(null); + let fileCount = 0; + for (const dir of [path.join(workdir, "supabase"), workdir]) { + for (const name of filenames) { + const filePath = path.join(dir, name); + const displayPath = path.relative(workdir, filePath); + const content = yield* fs.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + isAbsentFileReason(error.reason) + ? Effect.succeed(Option.none()) + : Effect.fail( + new CliConfigLoadError({ + message: `failed to read environment file: ${displayPath}`, + }), + ), + ), + ); + if (Option.isNone(content)) continue; + fileCount += 1; + const parsed = yield* Effect.try({ + try: () => parseDotEnv(content.value), + catch: () => + new CliConfigLoadError({ message: `failed to parse environment file: ${displayPath}` }), + }); + for (const [key, value] of Object.entries(parsed)) { + if (Object.hasOwn(values, key)) continue; + yield* shell.load([key]); + if (shell.get(key) !== undefined) continue; + values[key] = value; + files[key] = filePath; + } + } + } + yield* Effect.annotateCurrentSpan({ "file.count": fileCount }); + return { values, files } satisfies CliProjectEnvFiles; +}); diff --git a/apps/cli/src/shared/config/cli-config-env.unit.test.ts b/apps/cli/src/shared/config/cli-config-env.unit.test.ts new file mode 100644 index 0000000000..67d8760528 --- /dev/null +++ b/apps/cli/src/shared/config/cli-config-env.unit.test.ts @@ -0,0 +1,365 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { loadCliProjectEnvFiles, readShellEnvironment } from "./cli-config-env.ts"; +import { CliEnvNameSource } from "./cli-config-provider.layer.ts"; + +const withShell = (shell: Record) => + Layer.merge( + ConfigProvider.layer(ConfigProvider.fromEnvRecord(shell, { preserveEmptyStrings: true })), + Layer.succeed(CliEnvNameSource, () => Object.keys(shell)), + ); + +describe("ambient ConfigProvider", () => { + it.effect("yields Some('') for a set-but-empty variable when empty strings are preserved", () => + Effect.gen(function* () { + const value = yield* Config.option(Config.String("SUPABASE_EMPTY")); + + expect(value).toEqual(Option.some("")); + }).pipe(Effect.provide(withShell({ SUPABASE_EMPTY: "" }))), + ); + + it.effect( + "reports only the requested and prefix-matched names, keeping a requested empty value", + () => + Effect.gen(function* () { + const shell = yield* readShellEnvironment({ + names: ["SUPABASE_EMPTY", "SUPABASE_DB__PORT", "SUPABASE_MISSING"], + namesWithPrefix: ["DOTENV_PRIVATE_KEY"], + }); + + expect(Object.fromEntries(shell.entries())).toEqual({ + SUPABASE_EMPTY: "", + SUPABASE_DB__PORT: "1", + DOTENV_PRIVATE_KEY: "a", + DOTENV_PRIVATE_KEY_PRODUCTION: "b", + }); + }).pipe( + Effect.provide( + withShell({ + SUPABASE_EMPTY: "", + SUPABASE_DB__PORT: "1", + DOTENV_PRIVATE_KEY: "a", + DOTENV_PRIVATE_KEY_PRODUCTION: "b", + DOTENV_PRIVATE_KEYS_EXTRA: "c", + PLAIN: "d", + }), + ), + ), + ); +}); + +describe("shell snapshot", () => { + it.effect("reads names an orElse fallback provider holds", () => + Effect.gen(function* () { + const shell = yield* readShellEnvironment({ names: ["SUPABASE_X", "SUPABASE_DB_PORT"] }); + + expect(shell.get("SUPABASE_X")).toBe("1"); + expect(shell.get("SUPABASE_DB_PORT")).toBe("2"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.orElse( + ConfigProvider.fromEnvRecord({ SUPABASE_X: "1" }), + ConfigProvider.fromEnvRecord({ SUPABASE_DB_PORT: "2" }), + ), + ), + ), + ), + ); + + it.effect("reads requested names from a lookup-only provider", () => + Effect.gen(function* () { + const shell = yield* readShellEnvironment({ names: ["SUPABASE_DB_PORT"] }); + + expect(Object.fromEntries(shell.entries())).toEqual({ SUPABASE_DB_PORT: "3" }); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.make((path) => + Effect.succeed( + path.join("_") === "SUPABASE_DB_PORT" ? ConfigProvider.makeValue("3") : undefined, + ), + ), + ), + ), + ), + ); + + it.effect("sees a name added after the provider was built once it is loaded by name", () => + Effect.gen(function* () { + const record: Record = { SUPABASE_DB_PORT: "4" }; + const provider = ConfigProvider.fromEnvRecord(record); + record["SUPABASE_API_PORT"] = "5"; + + const shell = yield* readShellEnvironment({ names: ["SUPABASE_API_PORT"] }).pipe( + Effect.provide(ConfigProvider.layer(provider)), + ); + + expect(shell.get("SUPABASE_API_PORT")).toBe("5"); + }), + ); + + it.effect("never enumerates the provider", () => + Effect.gen(function* () { + const loaded: Array = []; + const inner = ConfigProvider.fromEnvRecord({ RUN_2000000: "x", SUPABASE_DB_PORT: "4" }); + const counting = ConfigProvider.make((path) => + Effect.suspend(() => { + loaded.push(path.join("_")); + return inner.load(path); + }), + ); + + const shell = yield* readShellEnvironment({ + names: ["SUPABASE_DB_PORT", "SUPABASE_DB_PORT", "SUPABASE_API_PORT"], + }).pipe(Effect.provide(ConfigProvider.layer(counting))); + + expect(shell.get("RUN_2000000")).toBeUndefined(); + expect(shell.get("SUPABASE_DB_PORT")).toBe("4"); + expect(loaded.sort()).toEqual(["SUPABASE_API_PORT", "SUPABASE_DB_PORT"]); + }), + ); + + it.effect("finds every shell-only dotenvx key by name, whatever its numeric suffix", () => + Effect.gen(function* () { + const shell = yield* readShellEnvironment({ namesWithPrefix: ["DOTENV_PRIVATE_KEY"] }); + + expect(shell.get("DOTENV_PRIVATE_KEY_2025")).toBe("a"); + expect(shell.get("DOTENV_PRIVATE_KEY_2026")).toBe("b"); + }).pipe( + Effect.provide( + withShell({ + DOTENV_PRIVATE_KEY_2025: "a", + DOTENV_PRIVATE_KEY_2026: "b", + }), + ), + ), + ); + + it.effect("loads nominated names through the provider, so an ambient-only name stays unset", () => + Effect.gen(function* () { + const shell = yield* readShellEnvironment({ namesWithPrefix: ["DOTENV_PRIVATE_KEY"] }); + + expect(shell.get("DOTENV_PRIVATE_KEY_2026")).toBeUndefined(); + }).pipe( + Effect.provide( + Layer.merge( + withShell({}), + Layer.succeed(CliEnvNameSource, () => ["DOTENV_PRIVATE_KEY_2026"]), + ), + ), + ), + ); + + it.effect("loads each requested name once", () => + Effect.gen(function* () { + let loads = 0; + const provider = ConfigProvider.make((path) => + Effect.sync(() => { + if (path.join("_") === "SUPABASE_ONCE") loads += 1; + return path.join("_") === "SUPABASE_ONCE" ? ConfigProvider.makeValue("1") : undefined; + }), + ); + + yield* Effect.gen(function* () { + const shell = yield* readShellEnvironment(); + yield* shell.load(["SUPABASE_ONCE"]); + yield* shell.load(["SUPABASE_ONCE"]); + }).pipe(Effect.provide(ConfigProvider.layer(provider))); + + expect(loads).toBe(1); + }), + ); +}); + +describe("project env loader", () => { + const workdir = useTempWorkdir("supabase-cli-config-env-"); + + const write = (relative: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(workdir.current, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + }); + + const fixtures: ReadonlyArray<{ + readonly name: string; + readonly files: Readonly>; + readonly shell: Readonly>; + readonly expected: Readonly>; + }> = [ + { + name: "first writer wins across the env-specific, local and plain files", + files: { + "supabase/.env": "A=plain\nB=plain\nC=plain", + "supabase/.env.local": "A=local\nB=local", + "supabase/.env.development": "A=dev", + "supabase/.env.development.local": "A=dev-local", + }, + shell: {}, + expected: { A: "dev-local", B: "local", C: "plain" }, + }, + { + name: "supabase/ is read before the project root", + files: { "supabase/.env": "A=nested", ".env": "A=root\nB=root" }, + shell: {}, + expected: { A: "nested", B: "root" }, + }, + { + name: "SUPABASE_ENV selects the env-specific files", + files: { "supabase/.env.staging": "A=staging", "supabase/.env.development": "A=dev" }, + shell: { SUPABASE_ENV: "staging" }, + expected: { A: "staging" }, + }, + { + name: "the test env skips .env.local", + files: { "supabase/.env.local": "A=local", "supabase/.env.test": "A=test" }, + shell: { SUPABASE_ENV: "test" }, + expected: { A: "test" }, + }, + { + name: "an empty SUPABASE_ENV falls back to development", + files: { "supabase/.env.development": "A=dev" }, + shell: { SUPABASE_ENV: "" }, + expected: { A: "dev" }, + }, + { + name: "a shell variable shadows the files even when it is empty", + files: { "supabase/.env": "A=file\nB=file\nC=file" }, + shell: { A: "shell", B: "" }, + expected: { C: "file" }, + }, + { + name: "godotenv quoting and variable expansion", + files: { "supabase/.env": 'BASE=one\nQUOTED="two words"\nCOMBINED="${BASE}-x"\nexport E=1' }, + shell: {}, + expected: { BASE: "one", QUOTED: "two words", COMBINED: "one-x", E: "1" }, + }, + ]; + + for (const fixture of fixtures) { + it.effect(`loads ${fixture.name}`, () => + Effect.gen(function* () { + for (const [relative, contents] of Object.entries(fixture.files)) { + yield* write(relative, contents); + } + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.values).toEqual(fixture.expected); + expect(Object.keys(loaded.files).sort()).toEqual(Object.keys(fixture.expected).sort()); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell(fixture.shell)))), + ); + } + + it.effect("records the file each value came from", () => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* write("supabase/.env.local", "A=local"); + yield* write(".env", "B=root"); + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.files).toEqual({ + A: path.join(workdir.current, "supabase", ".env.local"), + B: path.join(workdir.current, ".env"), + }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + it.effect("lets a lookup-only shell variable shadow a file key", () => + Effect.gen(function* () { + yield* write("supabase/.env", "A=file\nB=file"); + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.values).toEqual({ B: "file" }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + ConfigProvider.layer( + ConfigProvider.make((path) => + Effect.succeed( + path.join("_") === "A" ? ConfigProvider.makeValue("shell") : undefined, + ), + ), + ), + ), + ), + ), + ); + + it.effect("fails with the workdir-relative path when a file is unreadable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir.current, "supabase", ".env"), { recursive: true }); + const unreadable = yield* Effect.flip(loadCliProjectEnvFiles(workdir.current)); + expect(unreadable.message).toBe("failed to read environment file: supabase/.env"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + it.effect("skips a symlink loop like a missing file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* write("supabase/placeholder", ""); + yield* fs.symlink(".env", path.join(workdir.current, "supabase", ".env")); + yield* fs.symlink(".env", path.join(workdir.current, ".env")); + yield* write(".env.local", "C=local"); + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.values).toEqual({ C: "local" }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + it.effect("skips a supabase path that is a regular file", () => + Effect.gen(function* () { + yield* write("supabase", "not a directory"); + yield* write(".env", "A=root"); + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.values).toEqual({ A: "root" }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + it.effect("still fails when a dotenv path is a directory", () => + Effect.gen(function* () { + yield* write(".env/keep", ""); + + const failure = yield* Effect.flip(loadCliProjectEnvFiles(workdir.current)); + + expect(failure.message).toBe("failed to read environment file: .env"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + it.effect("parses a file that starts with a byte-order mark", () => + Effect.gen(function* () { + yield* write(".env", "\uFEFFA=1\nB=2"); + + const loaded = yield* loadCliProjectEnvFiles(workdir.current); + + expect(loaded.values).toEqual({ A: "1", B: "2" }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + + for (const file of ["supabase/.env.local", ".env.local"]) { + it.effect(`names ${file} on a malformed line without echoing its contents`, () => + Effect.gen(function* () { + yield* write(file, "not a valid line\n"); + + const failure = yield* Effect.flip(loadCliProjectEnvFiles(workdir.current)); + + expect(failure.message).toBe(`failed to parse environment file: ${file}`); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, withShell({})))), + ); + } +}); diff --git a/apps/cli/src/shared/config/cli-config-load.errors.ts b/apps/cli/src/shared/config/cli-config-load.errors.ts new file mode 100644 index 0000000000..1e6f5939b1 --- /dev/null +++ b/apps/cli/src/shared/config/cli-config-load.errors.ts @@ -0,0 +1,16 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** The config document, project env files, or remote selection could not be loaded. */ +export class CliConfigLoadError extends Data.TaggedError("CliConfigLoadError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/shared/config/cli-config-provider.layer.ts b/apps/cli/src/shared/config/cli-config-provider.layer.ts index 17a4385047..09db74de4e 100644 --- a/apps/cli/src/shared/config/cli-config-provider.layer.ts +++ b/apps/cli/src/shared/config/cli-config-provider.layer.ts @@ -1,6 +1,15 @@ -import { ConfigProvider, Layer } from "effect"; +import { ConfigProvider, Context, Layer } from "effect"; + +/** The process environment for synchronous leaf code that has no Effect context to read `Config` from. */ +export const ambientEnvironment = (): NodeJS.ProcessEnv => process.env; /** Installs the live process environment for the CLI's runtime config reads. */ export const cliConfigProviderLayer = Layer.sync(ConfigProvider.ConfigProvider, () => - ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), + ConfigProvider.fromEnvRecord(ambientEnvironment(), { preserveEmptyStrings: true }), +); + +/** Lists the environment variable names to look for by prefix; the provider still supplies their values. */ +export const CliEnvNameSource = Context.Reference<() => Iterable>( + "supabase/config/CliEnvNameSource", + { defaultValue: () => () => Object.keys(ambientEnvironment()) }, ); diff --git a/apps/cli/src/shared/config/cli-project-context.layer.ts b/apps/cli/src/shared/config/cli-project-context.layer.ts index acdc758e7e..b18bb89d55 100644 --- a/apps/cli/src/shared/config/cli-project-context.layer.ts +++ b/apps/cli/src/shared/config/cli-project-context.layer.ts @@ -1,6 +1,7 @@ -import { loadCliProjectEnvironment } from "@supabase/config/effect"; +import { findCliProjectPaths } from "@supabase/config/effect"; import { Effect, Layer, Option } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; +import { loadCliProjectEnvFiles, readShellEnvironment } from "./cli-config-env.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; const emptyCliProjectContext = CliProjectContext.of({ @@ -10,18 +11,18 @@ const emptyCliProjectContext = CliProjectContext.of({ const makeCliProjectContext = Effect.gen(function* () { const runtimeInfo = yield* RuntimeInfo; - const projectEnv = yield* loadCliProjectEnvironment({ - cwd: runtimeInfo.cwd, - baseEnv: process.env, - }); + const paths = yield* findCliProjectPaths(runtimeInfo.cwd); - if (projectEnv === null) { + if (paths === null) { return emptyCliProjectContext; } + const shell = yield* readShellEnvironment(); + const files = yield* loadCliProjectEnvFiles(paths.projectRoot, { shell }); + return CliProjectContext.of({ - paths: Option.some(projectEnv.paths), - projectEnv: Option.some(projectEnv), + paths: Option.some(paths), + projectEnv: Option.some({ values: files.values }), }); }); diff --git a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts index f10c95ee46..a3cc480871 100644 --- a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts @@ -69,6 +69,29 @@ describe("cliProjectContextLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("exposes project .env values except names the shell sets", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* makeTempDir; + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "FROM_FILE=file\nSHADOWED=file\n", + ); + + const cliProjectContext = yield* CliProjectContext.pipe( + Effect.provide(buildLayer(path, { cwd: projectRoot, env: { SHADOWED: "shell" } })), + ); + + expect( + Option.map(cliProjectContext.projectEnv, (projectEnv) => ({ ...projectEnv.values })), + ).toEqual(Option.some({ FROM_FILE: "file" })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("returns empty context when no supabase project is found", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/cli/src/shared/config/cli-project-context.service.ts b/apps/cli/src/shared/config/cli-project-context.service.ts index c4bf2ea1f1..c730b59628 100644 --- a/apps/cli/src/shared/config/cli-project-context.service.ts +++ b/apps/cli/src/shared/config/cli-project-context.service.ts @@ -4,7 +4,8 @@ import { Context } from "effect"; interface CliProjectContextShape { readonly paths: Option.Option; - readonly projectEnv: Option.Option; + /** Project `.env*` values only; a name the shell sets is never in here. */ + readonly projectEnv: Option.Option>; } export class CliProjectContext extends Context.Service()( diff --git a/apps/cli/src/shared/config/config-bool.ts b/apps/cli/src/shared/config/config-bool.ts new file mode 100644 index 0000000000..9b66e49373 --- /dev/null +++ b/apps/cli/src/shared/config/config-bool.ts @@ -0,0 +1,9 @@ +const BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); + +/** Parses a config bool string; returns `undefined` for anything outside the accepted forms. */ +export function parseBoolLiteral(value: string): boolean | undefined { + if (BOOL_TRUE.has(value)) return true; + if (BOOL_FALSE.has(value)) return false; + return undefined; +} diff --git a/apps/cli/src/command-internal/dotenv.ts b/apps/cli/src/shared/config/dotenv.ts similarity index 99% rename from apps/cli/src/command-internal/dotenv.ts rename to apps/cli/src/shared/config/dotenv.ts index 77af4075c4..4b899afa54 100644 --- a/apps/cli/src/command-internal/dotenv.ts +++ b/apps/cli/src/shared/config/dotenv.ts @@ -7,7 +7,7 @@ const EXPORT_PREFIX = /^\s*export\s+/; * empty or contains an invalid character. */ export function parseDotEnv(contents: string): Record { - const result: Record = {}; + const result: Record = Object.create(null); // Normalize CRLF→LF and scan the whole buffer with a cursor (not line-by-line), so a quoted // value can span newlines (e.g. a PEM block). let src = contents.replaceAll("\r\n", "\n"); diff --git a/apps/cli/src/command-internal/dotenv.unit.test.ts b/apps/cli/src/shared/config/dotenv.unit.test.ts similarity index 94% rename from apps/cli/src/command-internal/dotenv.unit.test.ts rename to apps/cli/src/shared/config/dotenv.unit.test.ts index c12e6f0ee8..e28ecc95e7 100644 --- a/apps/cli/src/command-internal/dotenv.unit.test.ts +++ b/apps/cli/src/shared/config/dotenv.unit.test.ts @@ -114,4 +114,10 @@ describe("parseDotEnv", () => { expect(parseDotEnv("foo=bar\nA=$foo")).toEqual({ foo: "bar", A: "$foo" }); }); }); + it("keeps a __proto__ name as its own variable", () => { + const parsed = parseDotEnv("__proto__=x\nconstructor=y"); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(parsed["__proto__"]).toBe("x"); + expect(parsed["constructor"]).toBe("y"); + }); }); diff --git a/apps/cli/src/shared/config/env-option.ts b/apps/cli/src/shared/config/env-option.ts new file mode 100644 index 0000000000..5ba74aa8f5 --- /dev/null +++ b/apps/cli/src/shared/config/env-option.ts @@ -0,0 +1,8 @@ +import { Config, Effect, Option } from "effect"; + +/** A variable from the ambient config provider; unset or unreadable is none. */ +export const envOption = (name: string): Effect.Effect> => + Config.option(Config.String(name)).pipe(Effect.orElseSucceed(() => Option.none())); + +export const envValue = (name: string): Effect.Effect => + Effect.map(envOption(name), Option.getOrUndefined); diff --git a/apps/cli/src/command-internal/parse-uint.ts b/apps/cli/src/shared/config/parse-uint.ts similarity index 95% rename from apps/cli/src/command-internal/parse-uint.ts rename to apps/cli/src/shared/config/parse-uint.ts index b644e664a8..c5214c4e1c 100644 --- a/apps/cli/src/command-internal/parse-uint.ts +++ b/apps/cli/src/shared/config/parse-uint.ts @@ -75,6 +75,12 @@ function parseBase0Digits( return { n }; } +/** The exact unsigned value of a base-0 token, or `undefined` when the token is not one. */ +export function parseBase0Bigint(token: string): bigint | undefined { + const parsed = parseBase0Digits(token, token); + return "cause" in parsed ? undefined : parsed.n; +} + export function parseUintBase0(token: string): ParseUintResult { const parsed = parseBase0Digits(token, token); // Magnitudes above 2^53 lose precision when converted to Number; only the accept/reject diff --git a/apps/cli/src/command-internal/parse-uint.unit.test.ts b/apps/cli/src/shared/config/parse-uint.unit.test.ts similarity index 100% rename from apps/cli/src/command-internal/parse-uint.unit.test.ts rename to apps/cli/src/shared/config/parse-uint.unit.test.ts diff --git a/apps/cli/src/shared/config/project-id.ts b/apps/cli/src/shared/config/project-id.ts new file mode 100644 index 0000000000..ab91dd230d --- /dev/null +++ b/apps/cli/src/shared/config/project-id.ts @@ -0,0 +1,10 @@ +const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; +const MAX_PROJECT_ID_LENGTH = 40; + +/** Invalid character runs become `_`, leading `_.-` is stripped, and the result is capped at 40. */ +export function sanitizeProjectId(src: string) { + const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); + return sanitized.length > MAX_PROJECT_ID_LENGTH + ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) + : sanitized; +} diff --git a/apps/cli/src/shared/config/seed-path.ts b/apps/cli/src/shared/config/seed-path.ts new file mode 100644 index 0000000000..2c782b3479 --- /dev/null +++ b/apps/cli/src/shared/config/seed-path.ts @@ -0,0 +1,53 @@ +import type { Path } from "effect"; + +/** + * Joins `pattern` under `supabase/`, collapsing `.`/`..` segments (e.g. + * `../seed.sql` → `seed.sql`). The cleaned, forward-slash-only path is the + * seed-tracking hash key, so an uncollapsed key would miss a previously + * recorded entry and re-run the seed. + */ +function joinSupabaseSeedPath(pattern: string): string { + const out: Array = []; + for (const segment of `supabase/${pattern}`.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); + else out.push(".."); + } else { + out.push(segment); + } + } + return out.length === 0 ? "." : out.join("/"); +} + +/** + * A bare leading separator (`/schemas`) has no Windows volume, so it's + * relative and joins under `supabase/` — unlike Node's win32 `isAbsolute`, + * which treats it as rooted at the current drive. + */ +const isAbsolutePattern = (pathSvc: Path.Path, pattern: string): boolean => { + if (process.platform !== "win32") { + return pathSvc.isAbsolute(pattern); + } + const isSeparator = (c: string | undefined): boolean => c === "/" || c === "\\"; + // Drive-letter volume (`C:\`, `c:/`): `volumeNameLen` accepts any byte before + // `:` (case 2, `path[1] === ':'`), then `IsAbs` requires a separator right after. + if (pattern.length >= 3 && pattern[1] === ":" && isSeparator(pattern[2])) { + return true; + } + // UNC volume (`\\server\share`, `//server/share`): `IsAbs` treats a + // double-separator-prefixed volume as absolute unconditionally. + return pattern.length >= 2 && isSeparator(pattern[0]) && isSeparator(pattern[1]); +}; + +/** + * Resolves a single seed/schema-paths entry: a relative pattern is joined + * under `supabase/`; an absolute (or empty) pattern is returned verbatim. + * Used by the reader for `[db.seed].sql_paths` and + * `[db.migrations].schema_paths`, and by `db reset` for its `--sql-paths` + * override — all three feed the glob the same resolved paths. + */ +export const resolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => + pattern.length === 0 || isAbsolutePattern(pathSvc, pattern) + ? pattern + : joinSupabaseSeedPath(pattern); diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 326601911c..89d1868892 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -1,12 +1,14 @@ -import { Option, type Path } from "effect"; +import { Effect, Option, type Path } from "effect"; + +import { envOption } from "./env-option.ts"; /** * Resolves the global Supabase CLI state root. * * `SUPABASE_HOME` overrides the location when set to a non-empty value after trimming * surrounding whitespace (an absolute path is expected; the value is used verbatim). Otherwise it - * defaults to `/.supabase`. A pure function, so every caller resolves through it with - * its own environment and home directory, keeping the contract in one place. + * defaults to `/.supabase`. Callers resolve through it with their own + * home directory, keeping the contract in one place. */ export const resolveSupabaseHomeValue = ( path: Pick, @@ -19,13 +21,7 @@ export const resolveSupabaseHomeValue = ( : path.join(homeDir, ".supabase"); }; -export const resolveSupabaseHome = ( - path: Pick, - env: Readonly>, - homeDir: string, -): string => - resolveSupabaseHomeValue( - path, - env["SUPABASE_HOME"] === undefined ? Option.none() : Option.some(env["SUPABASE_HOME"]), - homeDir, +export const readSupabaseHome = (path: Pick, homeDir: string) => + envOption("SUPABASE_HOME").pipe( + Effect.map((value) => resolveSupabaseHomeValue(path, value, homeDir)), ); diff --git a/apps/cli/src/shared/config/supabase-home.unit.test.ts b/apps/cli/src/shared/config/supabase-home.unit.test.ts index 8a6ca37b9e..5697229a8e 100644 --- a/apps/cli/src/shared/config/supabase-home.unit.test.ts +++ b/apps/cli/src/shared/config/supabase-home.unit.test.ts @@ -1,26 +1,29 @@ import { expect, layer } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Path } from "effect"; -import { resolveSupabaseHome } from "./supabase-home.ts"; +import { ConfigProvider, Effect, Path } from "effect"; -layer(BunServices.layer)("resolveSupabaseHome", (it) => { - it.effect("returns SUPABASE_HOME when set to a non-empty value", () => +import { withConfigEnv } from "../../../tests/helpers/command-mocks.ts"; +import { readSupabaseHome } from "./supabase-home.ts"; + +layer(BunServices.layer)("readSupabaseHome", (it) => { + const resolve = (env: Record) => Effect.gen(function* () { const path = yield* Path.Path; const home = path.join("/home", "test"); - expect(resolveSupabaseHome(path, { SUPABASE_HOME: "/custom/supabase" }, home)).toBe( - "/custom/supabase", - ); + return { home, path, resolved: yield* withConfigEnv(env, readSupabaseHome(path, home)) }; + }); + + it.effect("returns SUPABASE_HOME when set to a non-empty value", () => + Effect.gen(function* () { + const { resolved } = yield* resolve({ SUPABASE_HOME: "/custom/supabase" }); + expect(resolved).toBe("/custom/supabase"); }), ); it.effect("trims surrounding whitespace from SUPABASE_HOME", () => Effect.gen(function* () { - const path = yield* Path.Path; - const home = path.join("/home", "test"); - expect(resolveSupabaseHome(path, { SUPABASE_HOME: " /custom/supabase " }, home)).toBe( - "/custom/supabase", - ); + const { resolved } = yield* resolve({ SUPABASE_HOME: " /custom/supabase " }); + expect(resolved).toBe("/custom/supabase"); }), ); @@ -28,27 +31,24 @@ layer(BunServices.layer)("resolveSupabaseHome", (it) => { Effect.gen(function* () { const path = yield* Path.Path; const home = path.join("/home", "test"); - expect(resolveSupabaseHome(path, {}, home)).toBe(path.join(home, ".supabase")); + const resolved = yield* readSupabaseHome(path, home).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord({}))), + ); + expect(resolved).toBe(path.join(home, ".supabase")); }), ); it.effect("falls back to /.supabase when SUPABASE_HOME is empty", () => Effect.gen(function* () { - const path = yield* Path.Path; - const home = path.join("/home", "test"); - expect(resolveSupabaseHome(path, { SUPABASE_HOME: "" }, home)).toBe( - path.join(home, ".supabase"), - ); + const { home, path, resolved } = yield* resolve({ SUPABASE_HOME: "" }); + expect(resolved).toBe(path.join(home, ".supabase")); }), ); it.effect("falls back to /.supabase when SUPABASE_HOME is whitespace only", () => Effect.gen(function* () { - const path = yield* Path.Path; - const home = path.join("/home", "test"); - expect(resolveSupabaseHome(path, { SUPABASE_HOME: " " }, home)).toBe( - path.join(home, ".supabase"), - ); + const { home, path, resolved } = yield* resolve({ SUPABASE_HOME: " " }); + expect(resolved).toBe(path.join(home, ".supabase")); }), ); }); diff --git a/apps/cli/src/command-internal/temp-paths.ts b/apps/cli/src/shared/config/temp-paths.ts similarity index 98% rename from apps/cli/src/command-internal/temp-paths.ts rename to apps/cli/src/shared/config/temp-paths.ts index b77f4f7240..b635b57d1b 100644 --- a/apps/cli/src/command-internal/temp-paths.ts +++ b/apps/cli/src/shared/config/temp-paths.ts @@ -3,7 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; +} from "../telemetry/error-actionability.ts"; /** * A real failure reading `/supabase/.temp/project-ref` (e.g. the diff --git a/apps/cli/src/command-internal/temp-paths.unit.test.ts b/apps/cli/src/shared/config/temp-paths.unit.test.ts similarity index 53% rename from apps/cli/src/command-internal/temp-paths.unit.test.ts rename to apps/cli/src/shared/config/temp-paths.unit.test.ts index b217f3281c..ff4cce62b2 100644 --- a/apps/cli/src/command-internal/temp-paths.unit.test.ts +++ b/apps/cli/src/shared/config/temp-paths.unit.test.ts @@ -1,11 +1,8 @@ -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 { Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; -import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; import { ProjectRefReadError, readProjectRefFile, tempPaths } from "./temp-paths.ts"; const readRef = (workdir: string) => @@ -13,7 +10,7 @@ const readRef = (workdir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readProjectRefFile(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }); const REF = "abcdefghijklmnopqrst"; @@ -49,69 +46,60 @@ describe("tempPaths", () => { ); }); -describe("readProjectRefFile", () => { - it.effect("returns None when the project-ref file is absent (not linked)", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); +const withWorkdir = ( + setup: (fs: FileSystem.FileSystem, path: Path.Path, dir: string) => Effect.Effect, + run: (dir: string) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ref-" }); + yield* setup(fs, path, dir); + return yield* run(dir); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - it.effect("returns the trimmed ref when the file holds a value", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), ` ${REF}\n`); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v)).toBe(REF); - rmSync(dir, { recursive: true, force: true }); - }), +const seedRef = (contents: string) => (fs: FileSystem.FileSystem, path: Path.Path, dir: string) => + fs + .makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }) + .pipe( + Effect.andThen( + fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), contents), ), ); - }); - it.effect("treats a blank project-ref file as None", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), " \n"); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); +describe("readProjectRefFile", () => { + it.effect("returns None when the project-ref file is absent (not linked)", () => + withWorkdir(() => Effect.void, readRef).pipe( + Effect.tap((v) => Effect.sync(() => expect(Option.isNone(v)).toBe(true))), + ), + ); - it.effect("fails with ProjectRefReadError when the ref path is unreadable", () => { - // Returns `failed to load project ref` for a non-not-exist - // read error. Seeding project-ref as a DIRECTORY makes the - // read fail with EISDIR (a non-NotFound PlatformError), so it must surface, not - // collapse to "unlinked". - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp", "project-ref"), { recursive: true }); - return readRef(dir).pipe( - Effect.exit, - Effect.tap((exit) => + it.effect("returns the trimmed ref when the file holds a value", () => + withWorkdir(seedRef(` ${REF}\n`), readRef).pipe( + Effect.tap((v) => Effect.sync(() => expect(Option.getOrNull(v)).toBe(REF))), + ), + ); + + it.effect("treats a blank project-ref file as None", () => + withWorkdir(seedRef(" \n"), readRef).pipe( + Effect.tap((v) => Effect.sync(() => expect(Option.isNone(v)).toBe(true))), + ), + ); + + it.effect("fails with ProjectRefReadError when the ref path is unreadable", () => + withWorkdir( + (fs, path, dir) => + fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { recursive: true }), + (dir) => Effect.flip(readRef(dir)), + ).pipe( + Effect.tap((error) => Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("ProjectRefReadError"); - expect(json).toContain("failed to load project ref"); - } - rmSync(dir, { recursive: true, force: true }); + expect(error).toBeInstanceOf(ProjectRefReadError); + expect(error.message).toContain("failed to load project ref"); }), ), - ); - }); + ), + ); it("classifies an unreadable ref file as permission without an unrelated command", () => { const result = classifyCliErrorActionability( diff --git a/apps/cli/src/command-internal/vault-decrypt.ts b/apps/cli/src/shared/config/vault-decrypt.ts similarity index 76% rename from apps/cli/src/command-internal/vault-decrypt.ts rename to apps/cli/src/shared/config/vault-decrypt.ts index 358f36f66f..f88083aa4f 100644 --- a/apps/cli/src/command-internal/vault-decrypt.ts +++ b/apps/cli/src/shared/config/vault-decrypt.ts @@ -1,16 +1,8 @@ import { Buffer } from "node:buffer"; import { decrypt, PrivateKey } from "eciesjs"; -/** - * dotenvx vault-secret decryption: ECIES over secp256k1 (uncompressed - * ephemeral key, HKDF-SHA256 with no salt/info, AES-256-GCM with a 16-byte - * nonce) — the same wire format the JS `eciesjs` library produces, so this - * decrypts with `eciesjs` directly. - * - * An `encrypted:` value that cannot be decrypted aborts the whole command - * with `failed to parse config: `; the caller maps a non-`ok` result - * into that error. - */ +// dotenvx secrets use the `eciesjs` wire format. Callers turn a failed decrypt into +// `failed to parse config: `. const ENCRYPTED_PREFIX = "encrypted:"; const PRIVATE_KEY_ENV_PREFIX = "DOTENV_PRIVATE_KEY"; @@ -20,12 +12,7 @@ const STD_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/u; /** Whether a `[db.vault]` value is a dotenvx ciphertext. */ export const isEncryptedSecret = (value: string): boolean => value.startsWith(ENCRYPTED_PREFIX); -/** - * Collects dotenvx private keys from the environment: every - * `DOTENV_PRIVATE_KEY` or `DOTENV_PRIVATE_KEY_*` variable, comma-split with - * empties dropped. Enumeration order only matters when more than one - * distinct key could decrypt the same ciphertext (not a real scenario). - */ +/** Every `DOTENV_PRIVATE_KEY` or `DOTENV_PRIVATE_KEY_*` value, comma-split with empties dropped. */ export function collectDotenvPrivateKeys( env: Record, ): ReadonlyArray { @@ -68,7 +55,6 @@ function decryptWithKey(keyHex: string, encryptedValue: string): DecryptedSecret return { ok: false, error: "failed to base64 decode secret: invalid base64 data" }; } try { - // eciesjs returns a Uint8Array; wrap in Buffer before decoding the plaintext. const plaintext = Buffer.from(decrypt(privateKeyHex, Buffer.from(encoded, "base64"))); return { ok: true, value: plaintext.toString("utf8") }; } catch (cause) { @@ -94,3 +80,23 @@ export function decryptSecret( } return { ok: false, error: lastError }; } + +/** Decrypts every `encrypted:` value in a name-to-secret map, leaving plain values as-is; the first failure wins. */ +export function decryptSecretMap( + secrets: Readonly>, + keys: ReadonlyArray, +): + | { readonly ok: true; readonly value: Record } + | Extract { + const decryptedSecrets: Record = {}; + for (const [name, value] of Object.entries(secrets)) { + if (!isEncryptedSecret(value)) { + decryptedSecrets[name] = value; + continue; + } + const decrypted = decryptSecret(value, keys); + if (!decrypted.ok) return decrypted; + decryptedSecrets[name] = decrypted.value; + } + return { ok: true, value: decryptedSecrets }; +} diff --git a/apps/cli/src/command-internal/vault-decrypt.unit.test.ts b/apps/cli/src/shared/config/vault-decrypt.unit.test.ts similarity index 100% rename from apps/cli/src/command-internal/vault-decrypt.unit.test.ts rename to apps/cli/src/shared/config/vault-decrypt.unit.test.ts diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 343a0e8e08..64dd94307c 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -70,6 +70,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsLocalConfigLoader } from "./functions-config.ts"; import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; +import { ambientEnvironment } from "../config/cli-config-provider.layer.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; @@ -379,7 +380,7 @@ export function pruneRedundantDockerBinds( return entries.filter((entry) => !isCovered(entry)).map((entry) => entry.bind); } -function dockerNpmEnv(env: NodeJS.ProcessEnv = process.env): ReadonlyArray { +function dockerNpmEnv(env: NodeJS.ProcessEnv = ambientEnvironment()): ReadonlyArray { return dockerNpmEnvNames.flatMap((name) => { const value = env[name]; return value === undefined || value === "" ? [] : [name]; @@ -2518,7 +2519,7 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < // `--debug=false` must resolve to `false` — a plain presence check would get that backwards // (same rule as `download.ts`'s own `--debug` read). const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; - const deployConfig = context.loaded?.config; + const deployConfig = context.loaded.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( context.denoVersion, dependencies.edgeRuntimeVersion, @@ -2526,13 +2527,11 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < const configFunctions = yield* inferFunctionsManifest({ cwd: dependencies.projectRoot, config: deployConfig, - // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched - // past `dependencies.projectRoot` for either load, so they can never resolve two - // different projects. + // The resolved config never searches ancestors, so manifest inference must not either. search: false, }); - const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + const configDeclaredFunctions = deployConfig.functions; + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded.document); yield* validateConfigFunctionSlugs(configDeclaredFunctions); const slugs = flags.functionNames.length > 0 @@ -2595,10 +2594,9 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. - // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only. const networkMode = resolveDockerNetworkMode({ explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), - envOverride: yield* supabaseEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", context.projectEnvValues, ), diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index bd41b0babc..b11afbc94a 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -978,7 +978,7 @@ const downloadWithDockerUnbundle = Effect.fn("functions.download.dockerUnbundle" // — see that function's own doc comment. const networkMode = resolveDockerNetworkMode({ explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), - envOverride: yield* supabaseEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", projectEnvValues, ), diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 6e10391149..374f67d9ef 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -1,6 +1,9 @@ -import { Effect, type Crypto, type FileSystem, type Path } from "effect"; +import { Crypto, Effect, type FileSystem, Option, type Path } from "effect"; import type { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import type { LoadedCliConfig } from "@supabase/config/effect"; +import { CliConfigValues, type ResolvedCliConfig } from "../../config/cli-config-values.service.ts"; + +type FunctionsLoadedConfig = Pick; /** * Config resolution context shared by the `functions` Docker paths @@ -8,7 +11,10 @@ import type { LoadedCliConfig } from "@supabase/config/effect"; * {@link FunctionsLocalConfigLoader}. */ interface FunctionsCliConfigContext { - readonly loaded: LoadedCliConfig | null; + readonly loaded: FunctionsLoadedConfig; + readonly resolvedConfig: ResolvedCliConfig; + /** The config file's path; `undefined` when the project has none. */ + readonly configPath: string | undefined; /** Merged env with ambient values winning. */ readonly projectEnvValues: Readonly>; /** Sanitized project id, resolved after config validation. */ @@ -26,13 +32,15 @@ export interface FunctionsLocalConfigLoader { readonly projectRef: string | undefined; }) => Effect.Effect< { - readonly loaded: LoadedCliConfig | null; + readonly loaded: FunctionsLoadedConfig; + readonly resolvedConfig: ResolvedCliConfig; + readonly configPath: string | undefined; readonly projectEnvValues: Readonly>; readonly projectId: string; readonly denoVersion: number; }, Error, - FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto + FileSystem.FileSystem | Path.Path | RuntimeInfo | Crypto.Crypto | CliConfigValues >; } @@ -45,12 +53,22 @@ export const loadFunctionsCliConfig = Effect.fn("FunctionsConfig.load")(function readonly projectRef: string | undefined; readonly localConfigLoader: FunctionsLocalConfigLoader; }) { + const values = yield* CliConfigValues; + // Loaded first so a config failure reaches the caller as its own typed error, not as the + // validation hook's message-only wrapper; the hook's own load reuses this memoised resolved config. + yield* values.load({ + workdir: input.projectRoot, + projectRef: Option.fromNullishOr(input.projectRef), + }); + const context = yield* input.localConfigLoader.load({ projectRoot: input.projectRoot, projectRef: input.projectRef, }); return { loaded: context.loaded, + resolvedConfig: context.resolvedConfig, + configPath: context.configPath, projectEnvValues: context.projectEnvValues, projectId: context.projectId, denoVersion: context.denoVersion, diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 5b62ac3156..9ea76486bd 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -17,23 +17,14 @@ import { spawnContainerCli } from "../../command-internal/container-cli.ts"; import { makeDockerImageResolver } from "../../command-internal/docker-image-resolve.ts"; import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts"; - -const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; -const MAX_PROJECT_ID_LENGTH = 40; +import { sanitizeProjectId } from "../config/project-id.ts"; export function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); } -export function normalizeProjectId(source: string) { - const sanitized = source.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); - return sanitized.length > MAX_PROJECT_ID_LENGTH - ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) - : sanitized; -} - export function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${normalizeProjectId(projectId)}`; + return `supabase_${name}_${sanitizeProjectId(projectId)}`; } /** @@ -53,21 +44,21 @@ export function edgeRuntimeCacheVolume(projectId: string) { /** * Resolves the Docker network mode. `explicit` is tri-state: `undefined` - * (never set) falls through to `envOverride`; `""` (explicitly cleared) and - * any non-empty value both skip `envOverride` and resolve immediately. + * (never set) falls through to `envNetworkId`; `""` (explicitly cleared) and + * any non-empty value both skip `envNetworkId` and resolve immediately. * Callers must pass a flag reader that preserves this distinction — see * `lastExplicitLongFlagValue` (`shared/cli/flag-groups.ts`). */ export function resolveDockerNetworkMode(input: { readonly explicit: string | undefined; - readonly envOverride: string | undefined; + readonly envNetworkId: string | undefined; readonly projectId: string; }): string { if (input.explicit !== undefined) { return input.explicit.length > 0 ? input.explicit : localDockerId("network", input.projectId); } - if (input.envOverride !== undefined && input.envOverride.length > 0) { - return input.envOverride; + if (input.envNetworkId !== undefined && input.envNetworkId.length > 0) { + return input.envNetworkId; } return localDockerId("network", input.projectId); } diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 4846773a75..69992b99d0 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -317,7 +317,7 @@ describe("resolveDockerNetworkMode", () => { expect( resolveDockerNetworkMode({ explicit: "explicit-network", - envOverride: "env-network", + envNetworkId: "env-network", projectId: "my-project", }), ).toBe("explicit-network"); @@ -327,7 +327,7 @@ describe("resolveDockerNetworkMode", () => { expect( resolveDockerNetworkMode({ explicit: undefined, - envOverride: "env-network", + envNetworkId: "env-network", projectId: "my-project", }), ).toBe("env-network"); @@ -337,7 +337,7 @@ describe("resolveDockerNetworkMode", () => { expect( resolveDockerNetworkMode({ explicit: "", - envOverride: "env-network", + envNetworkId: "env-network", projectId: "my-project", }), ).toBe(localDockerId("network", "my-project")); @@ -347,7 +347,7 @@ describe("resolveDockerNetworkMode", () => { expect( resolveDockerNetworkMode({ explicit: undefined, - envOverride: "", + envNetworkId: "", projectId: "my-project", }), ).toBe(localDockerId("network", "my-project")); @@ -356,7 +356,7 @@ describe("resolveDockerNetworkMode", () => { it("generates supabase_network_ when both are unset", () => { const result = resolveDockerNetworkMode({ explicit: undefined, - envOverride: undefined, + envNetworkId: undefined, projectId: "my-project", }); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2036d05922..2aaa1ae55c 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1,10 +1,7 @@ import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts"; import { - CliConfigSchema, - findCliProjectPaths, inferFunctionsManifest, type CliConfig, - type CliProjectEnvironment, type ResolvedCliConfigValue, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; @@ -43,14 +40,9 @@ import { isContainerNotFoundMessage, spawnContainerCli, } from "../../command-internal/container-cli.ts"; -import { - loadCliConfig, - resolveCliConfigSubtree, - resolveCliConfigValue, -} from "../../command-internal/cli-config-load.ts"; import { inspectContainerState } from "../../command-internal/docker-lifecycle.ts"; import { isDockerDaemonUnreachable } from "../../command-internal/docker-suggest.ts"; -import { parseDotEnv } from "../../command-internal/dotenv.ts"; +import { parseDotEnv } from "../config/dotenv.ts"; import { supabaseEnvStringWithProjectFallback } from "../../command-internal/supabase-env.ts"; import { resolveRemoteJwks, @@ -81,7 +73,6 @@ import { localDockerId, nativeFailure, nativePlatformFailure, - normalizeProjectId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -98,8 +89,9 @@ import { ServeLocalDbInspectError, ServeLocalDbNotRunningError, } from "./serve.errors.ts"; -const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); -const defaultCliConfig = decodeCliConfig({}); +import { CliConfigKeys } from "../../config/cli-config-keys.ts"; +import { resolveConfigSubtree } from "../../config/cli-config-subtree.ts"; +import { CliConfigValues } from "../../config/cli-config-values.service.ts"; const dockerRuntimeServerPort = 8081; const dockerRuntimeInspectorPort = 8083; @@ -142,7 +134,6 @@ const externalTerminationExitCodes = new Set([ // Consecutive re-attaches to `docker logs -f` without forwarding a new line before giving up on // the stream and failing loudly instead of flooding replayed history forever. const containerLogReattachCap = 5; -const defaultSupabaseEnv = "development"; const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedFunctionsServeMainTemplate: string | undefined; @@ -709,94 +700,46 @@ const resolveServeConfig = Effect.fn("functions.serve.resolveConfig")(function* projectIdOverride: Option.Option, localConfigLoader: FunctionsLocalConfigLoader, ) { - const path = yield* Path.Path; - // Keeps `.env` discovery, config load, and functions-manifest inference - // from resolving three different roots: `search: false` must match - // `loadFunctionsCliConfig`'s own options exactly (see below). - const projectEnv = yield* loadServeCliProjectEnvironment(projectRoot); - const projectRef = Option.match(projectIdOverride, { - onNone: () => undefined, - onSome: (value) => { - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; - }, - }); - // We resolve the project environment ourselves (layering - // `.env.`/`.env.local`/`.env` over the ambient env) and pass - // it in, so `loadCliConfig`'s `env()` interpolation neither re-reads those - // files nor mutates `process.env`. - // - // `search`/`tomlOnly` here must match `loadFunctionsCliConfig`'s own - // options below exactly, or the two loads can resolve two different files, - // silently mixing fields from two different projects. - const loadedConfig = yield* loadCliConfig(projectRoot, { - ...(projectRef === undefined ? {} : { projectRef }), - ...(projectEnv === null ? {} : { cliProjectEnv: projectEnv }), - search: false, - tomlOnly: true, - }); - const baseConfig = loadedConfig?.config ?? defaultCliConfig; - - const auth = - projectEnv === null - ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig(yield* resolveCliConfigSubtree(baseConfig.auth, projectEnv, "auth")); - const edgeRuntime = - projectEnv === null - ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) - : toPlainEdgeRuntimeConfig( - yield* resolveCliConfigSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime"), - ); - const apiPort = - projectEnv === null - ? baseConfig.api.port - : (yield* resolveCliConfigSubtree(baseConfig.api, projectEnv, "api")).port; - const configDeclaredFunctions = - projectEnv === null - ? toPlainFunctionRecord(baseConfig.functions) - : toPlainFunctionRecord( - yield* resolveCliConfigSubtree(baseConfig.functions, projectEnv, "functions"), - ); - const configForManifest: CliConfig = { - ...baseConfig, - functions: configDeclaredFunctions, - }; + const projectRef = Option.getOrUndefined( + Option.filter( + Option.map(projectIdOverride, (value) => value.trim()), + (value) => value.length > 0, + ), + ); + yield* (yield* CliConfigValues).invalidate; + const context = yield* loadFunctionsCliConfig({ projectRoot, projectRef, localConfigLoader }); + const { resolvedConfig } = context; + const baseConfig = context.loaded.config; + + const auth = toPlainAuthConfig( + yield* resolveConfigSubtree(resolvedConfig, baseConfig.auth, "auth"), + ); + const edgeRuntime = toPlainEdgeRuntimeConfig( + yield* resolveConfigSubtree(resolvedConfig, baseConfig.edge_runtime, "edge_runtime"), + ); + const apiPort = (yield* resolvedConfig.get(CliConfigKeys.api.port)).value; + const configDeclaredFunctions = toPlainFunctionRecord( + yield* resolveConfigSubtree(resolvedConfig, baseConfig.functions, "functions"), + ); const configFunctions = yield* inferFunctionsManifest({ cwd: projectRoot, - config: configForManifest, + config: { ...baseConfig, functions: configDeclaredFunctions }, search: false, }); - const configProjectId = - projectEnv === null - ? (baseConfig.project_id ?? "") - : (reveal( - yield* resolveCliConfigValue(baseConfig.project_id ?? "", projectEnv, "project_id"), - ) ?? ""); - const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); - const fallbackProjectId = path.basename(path.resolve(projectRoot)); - - // A second, independent config/dotenv load, run before any Docker check so - // an invalid config fails here too; its `search`/`tomlOnly` must match the - // `loadedConfig` call above or the two loads can pick different files. - // Known gap: `projectId` only sees ambient-shell `SUPABASE_PROJECT_ID`, not - // project dotenv, so a project setting it only in `.env` gets a different - // Docker network than `deploy`/`download`/`start` — a silently broken `serve`. - const functionsCliConfig = yield* loadFunctionsCliConfig({ - projectRoot, - projectRef, - localConfigLoader, - }); return { - projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), + projectId: context.projectId, apiPort, auth, - edgeRuntime: { ...edgeRuntime, deno_version: functionsCliConfig.denoVersion }, + edgeRuntime: + context.denoVersion === undefined + ? edgeRuntime + : { ...edgeRuntime, deno_version: context.denoVersion }, configDeclaredFunctions, configFunctions, - rawConfigFunctions: rawFunctionConfigRecord(loadedConfig?.document), - configPath: loadedConfig?.path, - projectEnvValues: functionsCliConfig.projectEnvValues, + rawConfigFunctions: rawFunctionConfigRecord(context.loaded.document), + configPath: context.configPath, + projectEnvValues: context.projectEnvValues, } satisfies ServeResolvedConfig; }); @@ -1051,10 +994,6 @@ function validateDockerMultilineEnvNames(env: ReadonlyArray - value === undefined ? [] : [[key, value]], - ), - ); -} - -const loadServeCliProjectEnvironment = Effect.fn("functions.serve.loadProjectEnvironment")( - function* (projectRoot: string) { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const paths = yield* findCliProjectPaths(projectRoot, { search: false }); - if (paths === null) { - return null; - } - - const values: Record = ambientProjectEnv(); - const sources: Record = Object.fromEntries( - Object.keys(values).map((key) => [key, "ambient"]), - ); - const loadedPaths: string[] = []; - const env = values["SUPABASE_ENV"] || defaultSupabaseEnv; - - for (const dir of [paths.supabaseDir, paths.projectRoot]) { - for (const filename of loadDefaultEnvFilenames(env)) { - const envPath = path.join(dir, filename); - const contents = yield* readFileUtf8(fs, envPath).pipe( - Effect.catch((error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void - : nativePlatformFailure(error, envPath), - ), - ); - if (contents === undefined) { - continue; - } - loadedPaths.push(envPath); - const parsed = yield* Effect.try({ - try: () => parseDotEnv(contents), - catch: (cause) => nativeFailure(sanitizeDotEnvParseError(envPath, cause)), - }); - for (const [key, value] of Object.entries(parsed)) { - if (values[key] !== undefined) { - continue; - } - values[key] = value; - sources[key] = filename.includes(".local") ? ".env.local" : ".env"; - } - } - } - - return { paths, values, loadedPaths, sources } satisfies CliProjectEnvironment; - }, -); - /** * Whether any bind mounts something at `containerPath` or below it, i.e. whether * that path exists inside the container. Docker creates a missing `--workdir`, @@ -1990,7 +1873,7 @@ const startEdgeRuntime = Effect.fn("functions.serve.startEdgeRuntime")(function* return yield* Effect.gen(function* () { const networkMode = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(input.networkId), - envOverride: yield* supabaseEnvStringWithProjectFallback( + envNetworkId: yield* supabaseEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", resolved.projectEnvValues, ), diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index 33675d242d..c707def433 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -1,5 +1,6 @@ import { Effect, FileSystem, Path, Schema } from "effect"; import { promptYesNo } from "../../command-internal/prompt-yes-no.ts"; +import { sanitizeProjectId } from "../config/project-id.ts"; import { Output } from "../output/output.service.ts"; import { Tty } from "../runtime/tty.service.ts"; import { @@ -11,18 +12,6 @@ import { } from "./project-init.templates.ts"; import { InitParseSettingsError } from "./project-init.errors.ts"; -const invalidProjectId = /[^a-zA-Z0-9_.-]+/g; -const maxProjectIdLength = 40; - -function truncateText(text: string, maxLength: number): string { - return text.length > maxLength ? text.slice(0, maxLength) : text; -} - -function sanitizeProjectId(src: string): string { - const sanitized = src.replaceAll(invalidProjectId, "_").replace(/^[_.-]+/, ""); - return truncateText(sanitized, maxProjectIdLength); -} - // Strips line and block comments and trailing commas while preserving // string contents, so an existing JSONC settings file parses correctly. function stripJsonComments(contents: string): string { diff --git a/apps/cli/src/command-internal/debug-logger.service.ts b/apps/cli/src/shared/output/debug-logger.service.ts similarity index 100% rename from apps/cli/src/command-internal/debug-logger.service.ts rename to apps/cli/src/shared/output/debug-logger.service.ts diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index 18795fc7da..ce60c19ed8 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -4,7 +4,10 @@ import { cliErrorCode, formatCliErrorsForDisplay } from "../cli/subcommand-flag- export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { return { - formatHelpDoc: (doc: HelpDoc.HelpDoc) => JSON.stringify({ _tag: "Help", doc }), + formatHelpDoc: (doc: HelpDoc.HelpDoc) => { + const { annotations: _annotations, ...serializable } = doc; + return JSON.stringify({ _tag: "Help", doc: serializable }); + }, formatCliError: (error) => JSON.stringify({ _tag: "Error", diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 6dbd3a5729..09374fe90c 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -38,7 +38,10 @@ BranchesUnpauseNetworkError BranchesUnpauseUnexpectedStatusError BranchesUpdateNetworkError BranchesUpdateUnexpectedStatusError +CliConfigFlagConflictError +CliConfigLoadError CliConfigParseError +CliConfigValueError CliConfigWriteError CliProjectEnvParseError CliProjectHomeNotDirectoryError @@ -174,6 +177,7 @@ DbLintListSchemasError DbLintMalformedJsonError DbLintMutuallyExclusiveFlagsError DbLintQueryError +DbPasswordFlagsError DbPullDumpError DbPullEngineConflictError DbPullInSyncError @@ -352,7 +356,6 @@ MigrationLastZeroError MigrationMissingLocalError MigrationMissingRemoteError MigrationNewWriteError -MigrationPasswordFlagsError MigrationRepairUpdateError MigrationSeedError MigrationSquashBaselineError @@ -466,6 +469,7 @@ SecretsUnsetCancelledError SecretsUnsetNetworkError SecretsUnsetUnexpectedStatusError SeedConfigLoadError +SeedConsentRequiredError SeedMissingProjectConfigError SeedMutuallyExclusiveFlagsError SeedWorkdirError diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index b1ab675549..d9d63e3d8d 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -7,7 +7,7 @@ import { CommandSettings } from "../config/command-settings.service.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { GroupOrganization, GroupProject } from "../shared/telemetry/event-catalog.ts"; -import { readProjectRefFile, tempPaths } from "../command-internal/temp-paths.ts"; +import { readProjectRefFile, tempPaths } from "../shared/config/temp-paths.ts"; import { LinkedProjectCache } from "./linked-project-cache.service.ts"; function readString(obj: unknown, key: string): string { diff --git a/apps/cli/src/telemetry/telemetry-state.layer.ts b/apps/cli/src/telemetry/telemetry-state.layer.ts index 60e26ee1e8..1d39f5ca08 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.ts @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; import { isEphemeralIdentityRuntime } from "../shared/telemetry/identity.ts"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; +import { readSupabaseHome } from "../shared/config/supabase-home.ts"; import { TelemetryState } from "./telemetry-state.service.ts"; interface State { @@ -25,9 +25,9 @@ interface State { const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; -function telemetryPath(env: Record, pathSvc: Path.Path): string { - return pathSvc.join(resolveSupabaseHome(pathSvc, env, homedir()), "telemetry.json"); -} +const telemetryPath = Effect.fnUntraced(function* (pathSvc: Path.Path) { + return pathSvc.join(yield* readSupabaseHome(pathSvc, homedir()), "telemetry.json"); +}); /** * Serializes the state, splicing a carried exact `schema_version` token back in verbatim via @@ -392,7 +392,7 @@ export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState ) { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; - const filePath = telemetryPath(process.env, pathSvc); + const filePath = yield* telemetryPath(pathSvc); const exists = yield* fs.exists(filePath); const existing = exists ? yield* fs.readFileString(filePath) : undefined; const prior = existing !== undefined ? readExistingState(existing) : undefined; @@ -438,7 +438,7 @@ export const setTelemetryEnabled = Effect.fn("telemetry.setEnabled")(function* ( const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; const nextState: State = { ...state, enabled }; - const filePath = telemetryPath(process.env, pathSvc); + const filePath = yield* telemetryPath(pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeTelemetryState(nextState)); return nextState; @@ -458,7 +458,7 @@ const persistDistinctId = Effect.fn("telemetry.persistDistinctId")(function* ( const { distinct_id: _drop, ...rest } = base; const nextState: State = distinctId !== undefined && distinctId.length > 0 ? { ...rest, distinct_id: distinctId } : rest; - const filePath = telemetryPath(process.env, pathSvc); + const filePath = yield* telemetryPath(pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeTelemetryState(nextState)); }); @@ -469,7 +469,7 @@ const persistIdentityReset = Effect.fn("telemetry.persistIdentityReset")(functio const pathSvc = yield* Path.Path; const { distinct_id: _drop, ...rest } = base; const nextState: State = { ...rest, device_id: crypto.randomUUID() }; - const filePath = telemetryPath(process.env, pathSvc); + const filePath = yield* telemetryPath(pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeTelemetryState(nextState)); }); diff --git a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts index 83d1d08d25..e39571f8a3 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts @@ -8,6 +8,7 @@ import { Effect, Layer } from "effect"; import { afterEach, beforeEach } from "vitest"; import { mockAnalytics } from "../../tests/helpers/mocks.ts"; +import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../shared/telemetry/identity.ts"; import { @@ -17,6 +18,8 @@ import { } from "./telemetry-state.layer.ts"; import { TelemetryState } from "./telemetry-state.service.ts"; +const platformLayer = Layer.merge(BunServices.layer, cliConfigProviderLayer); + let tempHome: string; let prevHome: string | undefined; @@ -58,6 +61,7 @@ function makeLayer( Layer.provide(BunServices.layer), Layer.provide(analytics.layer), Layer.provide(runtime.layer), + Layer.provideMerge(cliConfigProviderLayer), ); } @@ -176,9 +180,9 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { describe("loadOrCreateTelemetryState (all-or-nothing recovery)", () => { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; - const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(platformLayer)); const runLoadAt = (now: Date) => - loadOrCreateTelemetryState({ now }).pipe(Effect.provide(BunServices.layer)); + loadOrCreateTelemetryState({ now }).pipe(Effect.provide(platformLayer)); it.effect("a bool-only file missing device_id/session_id is wholly regenerated", () => { writeFileSync(telemetryPath(), JSON.stringify({ enabled: false })); @@ -791,7 +795,7 @@ describe("loadOrCreateTelemetryState (all-or-nothing recovery)", () => { }); describe("exact int64 schema_version round-trip", () => { - const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(platformLayer)); // File contents are hand-built strings: `JSON.stringify(9007199254740993)` // would round inside the test itself, hiding exactly the bug under test. @@ -822,7 +826,7 @@ describe("exact int64 schema_version round-trip", () => { it.effect("setTelemetryEnabled's rewrite also preserves the exact token", () => { writeFileSync(telemetryPath(), fileWith("9007199254740993")); return Effect.gen(function* () { - yield* setTelemetryEnabled(true).pipe(Effect.provide(BunServices.layer)); + yield* setTelemetryEnabled(true).pipe(Effect.provide(platformLayer)); const written = readFileSync(telemetryPath(), "utf8"); expect(written).toContain('"enabled":true'); expect(written).toContain('"schema_version":9007199254740993'); diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 8a303da4f3..14af1df36f 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -26,6 +26,7 @@ import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as UrlParams from "effect/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; +import { ConfigEnvPins } from "./config-env-pins.ts"; import { CommandCredentials } from "../../src/auth/command-credentials.service.ts"; import { DbExecError } from "../../src/command-internal/db-connection.errors.ts"; import type { @@ -66,6 +67,7 @@ import type { Output } from "../../src/shared/output/output.service.ts"; import type { ProcessControl } from "../../src/shared/runtime/process-control.service.ts"; import type { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; import type { Tty } from "../../src/shared/runtime/tty.service.ts"; +import { CliEnvNameSource } from "../../src/shared/config/cli-config-provider.layer.ts"; import { Analytics } from "../../src/shared/telemetry/analytics.service.ts"; import { mockAnalytics, @@ -410,11 +412,9 @@ export function mockCommandSettings(opts: { readonly projectId?: Option.Option; readonly userAgent?: string; readonly supabaseHome?: string; - readonly dbPassword?: Option.Option>; readonly githubToken?: Option.Option>; }): Layer.Layer { return Layer.succeed(CommandSettings, { - dbPassword: opts.dbPassword ?? Option.none(), githubToken: opts.githubToken ?? Option.none(), profile: opts.profile ?? "supabase", profileEnvValue: opts.profileEnvValue ?? Option.none(), @@ -755,7 +755,9 @@ export function useTempWorkdir(prefix = "supabase-test-"): { /** * Sets `name` to `value` (or unsets it when `value` is `undefined`) for the duration of `body`, - * restoring whatever was there before. + * restoring whatever was there before — including a surrounding + * {@link useShadowCacheDisabled} pin, so a cache-subject test can opt back in. A set value is + * also pinned for the `Config` reads inside `body` (see {@link withConfigEnv}). */ export const withEnvVar = ( name: string, @@ -769,7 +771,10 @@ export const withEnvVar = ( else process.env[name] = value; return previous; }), - () => body, + () => + value === undefined + ? withoutConfigEnvPin(name, body) + : withConfigEnv({ [name]: value }, body), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env[name]; @@ -790,15 +795,33 @@ export const withConfigEnv = ( values: Readonly>, body: Effect.Effect, ): Effect.Effect => - body.pipe( - Effect.provide( - ConfigProvider.layerAdd( - ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), - { asPrimary: true }, + Effect.zip(Effect.service(ConfigEnvPins), Effect.service(CliEnvNameSource)).pipe( + Effect.flatMap(([pins, inheritedNames]) => + body.pipe( + Effect.provideService(ConfigEnvPins, { ...pins, ...values }), + Effect.provideService(CliEnvNameSource, () => [ + ...inheritedNames(), + ...Object.keys(values), + ]), + Effect.provide( + ConfigProvider.layerAdd( + ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), + { asPrimary: true }, + ), + ), ), ), ); +const withoutConfigEnvPin = ( + name: string, + body: Effect.Effect, +): Effect.Effect => + Effect.flatMap(Effect.service(ConfigEnvPins), (pins) => { + const { [name]: _removed, ...rest } = pins; + return Effect.provideService(body, ConfigEnvPins, rest); + }); + /** * Hides every ambient env var from the `Config` reads in `body`; `process.env` is untouched. * Where `loadLocalProjectContext` also reads `process.env` as its `baseEnv`, pair it with diff --git a/apps/cli/tests/helpers/command-tree.ts b/apps/cli/tests/helpers/command-tree.ts new file mode 100644 index 0000000000..a5185453c5 --- /dev/null +++ b/apps/cli/tests/helpers/command-tree.ts @@ -0,0 +1,47 @@ +import { Context, Option } from "effect"; +import type { Command, Param } from "effect/cli"; + +import { unwrapParam } from "../../src/command-internal/param-introspection.ts"; +import { commandInternals, flattenSubcommands } from "../../src/docs/docs-introspection.ts"; +import { + CliConfigFlagBindings, + type CliConfigFlagBinding, +} from "../../src/config/cli-config-flags.ts"; + +interface WalkedFlag { + readonly name: string; + readonly aliases: ReadonlyArray; + readonly param: Param.AnyFlag; +} + +export interface WalkedCommand { + readonly path: ReadonlyArray; + readonly command: Command.Command.Any; + readonly flags: ReadonlyArray; + readonly bindings: ReadonlyArray; +} + +/** Every command under `root` with its own declared flags and `withCliConfigFlags` bindings. */ +export const walkCommandTree = (root: Command.Command.Any): ReadonlyArray => { + const out: Array = []; + const visit = (command: Command.Command.Any, path: ReadonlyArray): void => { + const flags = commandInternals(command).config.flags.flatMap((param): Array => { + const single = unwrapParam(param)?.single; + return single === undefined + ? [] + : [{ name: single.name, aliases: single.aliases ?? [], param }]; + }); + out.push({ + path, + command, + flags, + bindings: Option.getOrElse( + Context.getOption(command.annotations, CliConfigFlagBindings), + () => [], + ), + }); + for (const child of flattenSubcommands(command)) visit(child, [...path, child.name]); + }; + visit(root, []); + return out; +}; diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 84e67bcd09..4ddb16ba48 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -13,6 +13,7 @@ import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts" import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; import { mockLinkedProjectCacheLayer } from "./command-mocks.ts"; +import { configValuesLayer } from "./config-values-layer.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; import { mockOutput, mockProcessControl, mockRuntimeInfo, mockTty } from "./mocks.ts"; @@ -356,7 +357,6 @@ const testCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => profile: "supabase", profileEnvValue: Option.none(), supabaseHome: path.join(workdir, ".supabase"), - dbPassword: Option.none(), githubToken: Option.none(), apiUrl: "https://api.supabase.com", projectHost: "supabase.co", @@ -487,6 +487,7 @@ export function setupCompute(options: ComputeSetupOptions) { mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), testCliConfigLayer(options.workdir, options.explicitWorkdir ?? false), + configValuesLayer(), testProjectRefLayer(options.linked !== false), telemetry.layer, mockLinkedProjectCacheLayer, diff --git a/apps/cli/tests/helpers/config-env-pins.ts b/apps/cli/tests/helpers/config-env-pins.ts new file mode 100644 index 0000000000..d35a3070ac --- /dev/null +++ b/apps/cli/tests/helpers/config-env-pins.ts @@ -0,0 +1,26 @@ +import { ConfigProvider, Context, Effect } from "effect"; + +/** Env values a test pinned; the test config layers build their shell tier from these alone. */ +export const ConfigEnvPins = Context.Reference>>( + "tests/ConfigEnvPins", + { defaultValue: () => ({}) }, +); + +export const PINNED_ENV_PREFIXES = ["SUPABASE_", "DOTENV_", "NEXT_PUBLIC_SUPABASE_"] as const; + +export const definedEnv = ( + env: Readonly>, +): Record => + Object.fromEntries( + Object.entries(env).flatMap(([name, value]): Array<[string, string]> => + value === undefined ? [] : [[name, value]], + ), + ); + +/** A provider over `explicit` plus the current pins, never the ambient `process.env`. */ +export const pinnedConfigProvider = ( + explicit: Readonly> = {}, +): Effect.Effect => + Effect.map(Effect.service(ConfigEnvPins), (pins) => + ConfigProvider.fromEnvRecord({ ...explicit, ...pins }, { preserveEmptyStrings: true }), + ); diff --git a/apps/cli/tests/helpers/config-goldens.ts b/apps/cli/tests/helpers/config-goldens.ts new file mode 100644 index 0000000000..af393c509e --- /dev/null +++ b/apps/cli/tests/helpers/config-goldens.ts @@ -0,0 +1,78 @@ +import { realpathSync } from "node:fs"; +import { afterEach, vi } from "vitest"; +import { Option, Redacted } from "effect"; + +import { PINNED_ENV_PREFIXES } from "./config-env-pins.ts"; + +/** + * Returns a function that replaces the whole `SUPABASE_*`/`DOTENV_*` slice of `process.env` with + * `values` for the current test; everything is restored after each test. + */ +export function useShellEnvPin(): (values: Readonly>) => void { + afterEach(() => { + vi.unstubAllEnvs(); + }); + return (values) => { + for (const name of Object.keys(process.env)) { + if (PINNED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) { + vi.stubEnv(name, undefined); + } + } + for (const [name, value] of Object.entries(values)) { + vi.stubEnv(name, value); + } + }; +} + +function compareSerialized(left: unknown, right: unknown): number { + const leftText = JSON.stringify(left); + const rightText = JSON.stringify(right); + if (leftText === rightText) return 0; + return leftText < rightText ? -1 : 1; +} + +function normalize(value: unknown): unknown { + if (value === undefined) return null; + if (typeof value === "function") return undefined; + if (Redacted.isRedacted(value)) return normalize(Redacted.value(value)); + if (Option.isOption(value)) return Option.isSome(value) ? normalize(value.value) : null; + if (value instanceof Set) return [...value].map(normalize).sort(compareSerialized); + if (value instanceof Map) return normalize(Object.fromEntries(value)); + if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); + if (Array.isArray(value)) return value.map(normalize); + if (typeof value === "object" && value !== null) { + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + const normalized = normalize((value as Record)[key]); + if (normalized !== undefined) out[key] = normalized; + } + return out; + } + return value; +} + +function resolvedPath(candidate: string): string { + try { + return realpathSync(candidate); + } catch { + return candidate; + } +} + +/** + * Stable golden serialization: sorted keys, `Option`/`undefined` collapsed to value/`null`, `Set` + * as sorted array, functions dropped, and each `redactions` key (e.g. a temp workdir) replaced + * by its placeholder. + */ +export function goldenJson( + value: unknown, + redactions: Readonly> = {}, +): string { + let text = `${JSON.stringify(normalize(value), null, 2)}\n`; + for (const [needle, placeholder] of Object.entries(redactions)) { + for (const variant of new Set([needle, resolvedPath(needle)])) { + text = text.replaceAll(JSON.stringify(variant).slice(1, -1), placeholder); + } + } + return text; +} diff --git a/apps/cli/tests/helpers/config-values-layer.integration.test.ts b/apps/cli/tests/helpers/config-values-layer.integration.test.ts new file mode 100644 index 0000000000..235b427568 --- /dev/null +++ b/apps/cli/tests/helpers/config-values-layer.integration.test.ts @@ -0,0 +1,90 @@ +import { BunServices } from "@effect/platform-bun"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Option } from "effect"; + +import { CliConfigKeys } from "../../src/config/cli-config-keys.ts"; +import { CliConfigValues } from "../../src/config/cli-config-values.service.ts"; +import { withConfigEnv, withEnvVar } from "./command-mocks.ts"; +import { cliConfigValuesTestLayer, configValuesLayer } from "./config-values-layer.ts"; +import { mockOutput } from "./mocks.ts"; + +const STRAY_PORT = "11111"; +const AMBIENT_NAME = "SUPABASE_DB_PORT"; + +let previous: string | undefined; +beforeEach(() => { + previous = process.env[AMBIENT_NAME]; + process.env[AMBIENT_NAME] = STRAY_PORT; +}); +afterEach(() => { + if (previous === undefined) delete process.env[AMBIENT_NAME]; + else process.env[AMBIENT_NAME] = previous; +}); + +const readDbPort = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-hermetic-layer-" }); + const resolvedConfig = yield* CliConfigValues.use((values) => + values.load({ workdir, projectRef: Option.none() }), + ); + return yield* resolvedConfig.get(CliConfigKeys.db.port); +}).pipe(Effect.provide(BunServices.layer), Effect.scoped); + +describe("hermetic config test layers", () => { + it.live("cliConfigValuesTestLayer never reads a stray ambient SUPABASE_ variable", () => + readDbPort.pipe( + Effect.provide(cliConfigValuesTestLayer), + Effect.tap((port) => + Effect.sync(() => { + expect(port.value).toBe(54322); + expect(port.origin).toEqual({ tier: "default" }); + }), + ), + ), + ); + + it.live("configValuesLayer never reads a stray ambient SUPABASE_ variable", () => + readDbPort.pipe( + Effect.provide(configValuesLayer({ output: mockOutput().layer })), + Effect.tap((port) => + Effect.sync(() => { + expect(port.value).toBe(54322); + expect(port.origin).toEqual({ tier: "default" }); + }), + ), + ), + ); + + it.live("cliConfigValuesTestLayer sees a value pinned through withConfigEnv", () => + withConfigEnv({ [AMBIENT_NAME]: "22222" }, readDbPort).pipe( + Effect.provide(cliConfigValuesTestLayer), + Effect.tap((port) => + Effect.sync(() => { + expect(port.value).toBe(22222); + expect(port.origin).toMatchObject({ tier: "shell", envName: AMBIENT_NAME }); + }), + ), + ), + ); + + it.live("cliConfigValuesTestLayer sees a value pinned through withEnvVar", () => + withEnvVar(AMBIENT_NAME, "33333", readDbPort).pipe( + Effect.provide(cliConfigValuesTestLayer), + Effect.tap((port) => Effect.sync(() => expect(port.value).toBe(33333))), + ), + ); + + it.live("configValuesLayer sees only the env passed to it", () => + readDbPort.pipe( + Effect.provide( + configValuesLayer({ output: mockOutput().layer, env: { [AMBIENT_NAME]: "44444" } }), + ), + Effect.tap((port) => + Effect.sync(() => { + expect(port.value).toBe(44444); + expect(port.origin).toMatchObject({ tier: "shell", envName: AMBIENT_NAME }); + }), + ), + ), + ); +}); diff --git a/apps/cli/tests/helpers/config-values-layer.ts b/apps/cli/tests/helpers/config-values-layer.ts new file mode 100644 index 0000000000..f06588effc --- /dev/null +++ b/apps/cli/tests/helpers/config-values-layer.ts @@ -0,0 +1,80 @@ +import { BunServices } from "@effect/platform-bun"; +import { ConfigProvider, Effect, Layer } from "effect"; + +import { + CliConfigFlagInputs, + makeCliConfigFlagInputs, + type CliConfigFlagAssignment, +} from "../../src/config/cli-config-flags.ts"; +import { cliConfigValuesLayer } from "../../src/config/cli-config-values.layer.ts"; +import { CliConfigValues } from "../../src/config/cli-config-values.service.ts"; +import type { DebugLogger } from "../../src/shared/output/debug-logger.service.ts"; +import type { Output } from "../../src/shared/output/output.service.ts"; +import { CliEnvNameSource } from "../../src/shared/config/cli-config-provider.layer.ts"; +import { ConfigEnvPins, pinnedConfigProvider } from "./config-env-pins.ts"; +import { mockOutput } from "./mocks.ts"; + +/** + * Runs every `load` of `real` over a shell tier built from `explicit` plus the pins in scope at + * the call, never over the ambient `process.env`. + */ +export const withHermeticShellTier = ( + real: CliConfigValues["Service"], + explicit: Readonly> = {}, +): CliConfigValues["Service"] => + CliConfigValues.of({ + ...real, + load: (target) => + Effect.flatMap(Effect.service(ConfigEnvPins), (pins) => + Effect.flatMap(pinnedConfigProvider(explicit), (provider) => + real.load(target).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provideService(CliEnvNameSource, () => Object.keys({ ...explicit, ...pins })), + ), + ), + ), + }); + +export const flagInput = (path: string, flag: string, value: unknown): CliConfigFlagAssignment => ({ + path, + flag, + value, +}); + +/** + * The real `CliConfigValues` service over the real filesystem, rebuilt per provide so no resolved config + * memo leaks. Its shell tier holds only `options.env` and the pins in scope at each load + * (`withConfigEnv`, `withEnvVar`, `processEnvLayer`), never the ambient `process.env`. + */ +export const configValuesLayer = ( + options: { + readonly output?: Layer.Layer; + readonly flags?: ReadonlyArray; + readonly env?: Readonly>; + readonly debugLogger?: Layer.Layer; + } = {}, +) => + Layer.fresh( + Layer.effect( + CliConfigValues, + Effect.map(Effect.service(CliConfigValues), (real) => + withHermeticShellTier(real, options.env), + ), + ).pipe( + Layer.provide( + cliConfigValuesLayer.pipe( + Layer.provide( + Layer.mergeAll( + BunServices.layer, + options.output ?? mockOutput().layer, + options.debugLogger ?? Layer.empty, + Layer.succeed(CliConfigFlagInputs, makeCliConfigFlagInputs(options.flags)), + ), + ), + ), + ), + ), + ); + +/** {@link configValuesLayer} with no flags bound and no env. */ +export const cliConfigValuesTestLayer = configValuesLayer(); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index e67e985305..1f579456cb 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -2,6 +2,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; +import { ConfigEnvPins, definedEnv } from "./config-env-pins.ts"; import { Console, ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; import { testRunEnvVar as stackTestRunEnvVar } from "@supabase/stack/internal/test-run-label"; @@ -567,7 +568,7 @@ function applyProcessEnv(values: Readonly>) { export function processEnvLayer( values: Readonly> = {}, ): Layer.Layer { - return ConfigProvider.layer( + const providerLayer = ConfigProvider.layer( Effect.acquireRelease( Effect.sync(() => { const ambientTestRun = process.env[stackTestRunEnvVar]; @@ -587,6 +588,11 @@ export function processEnvLayer( }), ).pipe(Effect.map(({ provider }) => provider)), ); + const pinsLayer = Layer.effect( + ConfigEnvPins, + Effect.sync(() => definedEnv(process.env)), + ); + return pinsLayer.pipe(Layer.provideMerge(providerLayer)); } export function mockCliProjectContext( diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index b6518750fc..716f30b25d 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -25,6 +25,7 @@ import { YesFlag } from "../../src/command-internal/global-flags.ts"; import { StackApi } from "../../src/command-internal/stack-api.ts"; import { stackBackendLayer } from "../../src/command-internal/stack-backend.ts"; import type { OutputFormat } from "../../src/shared/output/types.ts"; +import { cliConfigValuesTestLayer } from "./config-values-layer.ts"; import { mockOutput, mockRuntimeInfo, mockStdin, mockTty } from "./mocks.ts"; import { unusedStackServices } from "./unused-stack.ts"; import { @@ -426,6 +427,7 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { linkedCache.layer, mockCommandSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), BunServices.layer, + cliConfigValuesTestLayer, projectRefLayer, Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), diff --git a/docs/adr/0031-config-value-precedence.md b/docs/adr/0031-config-value-precedence.md new file mode 100644 index 0000000000..95c2cd3068 --- /dev/null +++ b/docs/adr/0031-config-value-precedence.md @@ -0,0 +1,184 @@ +# 0031. Config value precedence + +**Status**: proposed +**Date**: 2026-10-08 + +## Problem Statement + +Each command family read config values through its own overlay: the db reader, the start and stack +readers, `config push` and the telemetry gate each decided separately how flags, environment +variables, `.env` files, `[remotes.*]` blocks and `config.toml` combine. They disagreed. A matched +`[remotes.*]` block could beat an explicit `SUPABASE_*` variable, a variable could be honoured by +`db push` and ignored by `start`, and a project `.env` value reached some commands only. + +## Decision + +- Every config value resolves in one order: explicit flag, shell environment, project `.env*` + files, config (`config.json` preferred over `config.toml`, a matched `[remotes.*]` block over the + base document), default. One pure function, `pickCliConfigKey`, implements it, and the tier order + is a constant. +- Commands read values through the `CliConfigValues` service. `load({ workdir, projectRef })` + returns a resolved config, memoised per workdir, project ref and flag set within a runtime. The resolved config + decodes the whole config eagerly, so an invalid value fails every command that loads config + unless the caller passes `tolerateInvalid`. Load-time warnings (an env value + overriding a remote, the `[inbucket]` deprecation) print once per runtime, however many + resolved configs it loads. Code that writes config or `.temp` goes through `writeThrough`, which drops + the memo. +- The resolved config surface is: + - `get(key)`: the value and the origin tier that supplied it. + - `loaded`: what the project declares, with every flag, env and secret winner written in and no + defaults. `fileDeclared`: what the config file alone declares, with `env()` resolved and no + flag or `SUPABASE_*` overlay. + - `materialized`: `loaded` plus defaults and normalizers, with `originAt(path)`. `origins`: the + origin of every registry key. + - `appliedRemote`, `hasConfigFile`, `declares(path)`, `familyNames(family)`, `invalid`, + `withheldEnv`, `dotenvPrivateKeys`. + - `envValues(names)`: the non-empty value of each named variable, shell before project `.env*`, + for resolving `env(NAME)` references. + - `projectEnvValues`: the raw project `.env*` record, for names outside the registry only (see + the exceptions). +- The key registry is generated from `CliConfigSchema`. Each leaf gets a path, the env name + `SUPABASE_` plus the upper-snake path, and a codec derived from its type. Hand-written + annotations cover what the schema cannot express: codec overrides, secret + and section-gated keys, context defaults, exclusions and key families. Document-only keys such as + `db.password` have no env tier. +- A flag binds to a key with `key.flag(...)`, declared in the annotations. The flag supplies the + highest tier. `withCliConfigFlags` collects a command's bindings, and two flags assigning the + same key fail with an error. +- Env for a section-gated key applies only when the section exists in the merged document, so + `SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN` does not conjure a provider block. +- Remote selection uses the effective `project_id` of each `[remotes.*]` block, which is + `SUPABASE_REMOTES__PROJECT_ID` when set, else the TOML value. +- Credential scoping: an env source for a key marked `linkedTarget` (the linked database password) + is withheld when the target project differs from the one in `.temp/project-ref`. This changes + which sources are available, not the tier order. The CLI prints a notice naming the ignored + variable; without a password it mints a temporary login role. +- Seed consent: when a target matches a `[remotes.*]` block, a matched block that does not declare + `db.seed.enabled` seeds nothing by default. `db push` and `db reset --linked` ask before seeding + into the matched project (`--yes` or `SUPABASE_YES` skips the prompt); a non-interactive run + without `--yes` exits 1. `db push` still seeds only with `--include-seed`. +- Local SMTP is on when `[auth.email.smtp]` sets `enabled`, or when the table is present and + leaves it out. The schema default alone is off, so a partial table such as one holding only + `pass = "env(SMTP_PASS)"` still loads. `resolveSmtpEnabled` implements the rule for every + reader, and `config push` and `config pull` see the same value. +- Exceptions kept on purpose: + - `CommandSettings` reads `SUPABASE_PROJECT_ID` from the shell only, because it selects the + target project before any config is loaded. + - `resolveExperimentalFeature` ignores remotes and `.env`, because it runs before the target + project is known. + - `experimental.pgdelta` is exempt from section gating: `enabled` defaults to true, so the env + rollback (`SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false`) applies while the section is absent. + `SUPABASE_EXPERIMENTAL_PG_DELTA` is not read. + - `db.password` has no env tier: the local database password lives in config, and the linked + password is a separate key with its own env name, flag and scoping. + - Credential scoping covers the linked database password only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` + is not scoped to the linked project. + - Flag ownership covers the names in `CLI_CONFIG_FLAGS` only. A command may declare any other + flag, with or without a config key behind it. + - `resolvedConfig.projectEnvValues` serves names outside the registry: Docker and registry resolution, + the services hostname, Bitbucket detection, `SUPABASE_YES`, `SUPABASE_NETWORK_ID`, and the + project env `functions serve` forwards to the edge runtime. A registry name read from it is a + guard failure. + - `secrets set` loads with `tolerateInvalid`, so an invalid value elsewhere in the config does + not block setting secrets; it reads `edge_runtime` through `resolveCliSubtree`. + - `--include-seed` beats `db.seed.enabled = false` in the base config; the flag is the + highest tier for the decision it names. + - `layeredParseEnv` in `db-config.parse.ts` looks up libpq `PG*` names, which are not registry + keys, so it stays outside the registry. + - The telemetry event catalog lists `SUPABASE_PROJECT_ID` as an environment signal; it records + which variables are set and never resolves a value. +- The pipeline stages live in `@supabase/config/internal` and are not covered by semver. The CLI + composes them: parse, merge a selected remote, overlay values, decode. + +## Rationale + +One implementation of the order removes the class of bug where two readers disagree. Generating +the registry from the schema means a new schema field gets an env override without a second edit. +Eager whole-config decode costs a failure on unrelated invalid values but makes every reader see +the same config. Credential scoping is source availability rather than a different order, so the +order stays the same for every key. + +## Consequences + +### Positive + +- A flag, then env, then `.env`, then config, behaves identically across commands. +- A matched remote no longer shadows an explicit environment variable. +- Four more leaves are overridable from the environment: `auth.sms.otp_expiry`, + `auth.sms.otp_length`, `db.network_restrictions.allowed_cidrs` and + `db.network_restrictions.allowed_cidrs_v6`. + +### Negative + +- Breaking changes, listed in the pull request: an invalid `SUPABASE_EXPERIMENTAL_STACK` + value now fails, `--password` is rejected for commands that + default to the local database, `db reset --linked` can ask a second prompt, an invalid config + value fails every command that loads config, `services` and `functions` read `config.json` + first, and `config push` pushes env-overridden values. +- The remote and credential rules are subtle enough that a reader must consult the resolved config's + `origin` to know why a value won. + +### Guardrails + +- `code-structure.unit.test.ts` fails when: + - a registry env name appears as a string literal outside the foundation files + (`config/cli-config-*.ts`, `shared/config/cli-config-*.ts`), whatever the read pattern; only + the telemetry event catalog is exempt; + - the old overlay identifiers return; + - a foundation file imports from `commands/` or `command-internal/`; + - `CliConfigFlagInputs` is constructed outside `cli-config-flags.ts`; + - a registry-backed flag is declared with a raw `Flag.*` instead of `key.flag`; + - `process.env`, `Bun.env` or an aliased form is read outside the foundation, the entrypoint + and `shared/compute/stacks/**` templates; + - a file outside the foundation imports `loadCliConfig`, `resolveCliConfigSubtree` or + `loadCliProjectEnvironment` from `@supabase/config`, tests included for the last. +- `oxlint` bans `process.env`, `Bun.env`, `globalThis.process`, `env` imported from `node:process`, + `process` or `bun`, and namespace imports of `node:process` across `apps/cli/src`, except the + config provider, the env loader, the entrypoint, `shared/compute/stacks/**` and tests. Importing + `ambientEnvironment` is limited to nine audited files that read terminal, libpq, Docker and proxy + variables, never a registry name: `cli/complete.ts`, `command-internal/colors.ts`, + `command-internal/db-config.parse.ts`, `command-internal/hostname.ts`, + `command-internal/pgpass.ts`, `commands/login/login-claude-hint.ts`, + `commands/start/lib/env-or-default.ts`, `commands/start/services/vector.service.ts` and + `shared/functions/deploy.ts`. A default-import alias of `node:process` is caught by the + code-structure guard instead. +- `CliConfigFlagInputs` is not an allowed runtime service, so a command that reads config values + without `withCliConfigFlags` fails `tsc`. +- Registry unit tests check env-name uniqueness, section gating, and that every + schema leaf is in the registry or explicitly excluded. +- `cli-config-contract.unit.test.ts` walks every registry key and family field and checks that + each resolves from the highest tier that can supply it, with that tier's origin, that a lower + tier wins only when every higher one is unavailable, and that an empty shell variable falls + through. It also pins secret keys and the declared flags to real keys. +- `cli-config-flag-ownership.unit.test.ts` walks the command tree, hidden commands included, and + fails when a flag the registry owns is missing from a command, bound to another key, or bound by + a command that does not declare it. It checks the names in `CLI_CONFIG_FLAGS` only. + +### Adding a key or a flag + +- A new `CliConfigSchema` leaf joins the registry automatically. Add an annotation only for a + non-default codec, a secret, a section gate or an exclusion; a leaf with no codec must + be listed in `CLI_CONFIG_SCHEMA_EXCLUDED` or registry construction throws. +- To bind a flag, add it to `CLI_CONFIG_FLAGS`, declare it with `key.flag` in the command, and + pipe the command config through `withCliConfigFlags`. +- Read the value with `resolvedConfig.get(CliConfigKeys.)`. Never read the env name directly. +- A new exception to any rule above is a decision: record it in this ADR and in the guard's + exemption list together. + +## Alternatives Considered + +1. **Fix each reader to match the others**: keeps the duplicated overlays, so they drift again. +2. **Let remotes override env**: matches the old db behaviour but makes an explicit variable + unable to win over a file. +3. **Reorder tiers for credentials**: hides the order behind per-key exceptions; scoping the + sources keeps one order. + +## Related Decisions + +- [ADR 0020](0020-config-naming-vocabulary.md): the config vocabulary this decision uses. +- [ADR 0021](0021-projectconfig-convergence-semantics.md): the hosted subset that shares the schema. + +## See Also + +- [CLI config loading](../../packages/config/docs/cli-config-loading.md) +- [`apps/cli` agent rules](../../apps/cli/AGENTS.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index ab0634baa6..bbac0486b2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -118,3 +118,4 @@ Why did we choose this approach? ``` > **Note**: Additional sections (Implementation Notes, Open Questions, Verification Checklist) may be added as needed. +> | 0031 | [Config Value Precedence](0031-config-value-precedence.md) | proposed | diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 0f36ee5637..adb918dc71 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -45,8 +45,11 @@ artifacts (`./schema.json`, `./project-schema.json`). `ConfigChange`/`ConfigChangeClass`/`ConfigChangeCounts`/`ConfigChangeSet`/ `DiffProjectConfigOptions` types, lives on `.`, not here — see above), `dualScopeProjectConfigPaths`, the raw `[remotes.*]` helpers `remoteNameForProjectRef`/ - `remoteProjectIdEntries`, and the atomic single-file writer `writeCliConfigDocumentText`/ - `CliConfigWriteError` — kept off `./effect`'s public surface deliberately (no consumer outside + `remoteProjectIdEntries`, the atomic single-file writer `writeCliConfigDocumentText`/ + `CliConfigWriteError`, and the two pipeline stages `loadCliConfig` is built from — + `parseCliConfigDocumentFile` (discover and parse) with `mergeParsedCliConfig` (merge the + `[remotes.*]` block a caller-supplied `selectRemote` picks) and `decodeMergedCliConfig` (interpolate `env()` against a caller-supplied + env record, then decode and validate, optionally on an overlaid document) — kept off `./effect`'s public surface deliberately (no consumer outside `apps/cli` needs them, and internal-only keeps the published semver surface unchanged). Anything here can change or vanish in any release. - `@supabase/config/schema.json` — generated JSON Schema (draft 2020-12) for `CliConfig` (a diff --git a/packages/config/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 6f31e982c0..d842344919 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -75,10 +75,11 @@ an unrelated verb. CLI-2234 renamed all four to match. ## Overview -There is no global, fully-resolved config snapshot. Most `env(NAME)` references inside `CliConfig` -are substituted automatically when the file is loaded (see "Raw Config Loading" below). A narrow -set of fields are deliberately left as literal `env(NAME)` strings through decode, and are resolved -by a caller later, on demand (see "Lazy `env(NAME)` Resolution" below). +The package itself has no global, fully-resolved resolved config. Most `env(NAME)` references +inside `CliConfig` are substituted automatically when the file is loaded (see "Raw Config Loading" +below). A narrow set of fields are deliberately left as literal `env(NAME)` strings through decode, +and are resolved by a caller later, on demand (see "Lazy `env(NAME)` Resolution" below). The CLI +builds its own per-command resolved config on top of the package stages (see "CLI value precedence"). ## Project Discovery @@ -159,8 +160,9 @@ config file already had. That key does not participate in runtime config semanti ## Env Loading and Precedence -`loadCliProjectEnvironment()` (exported from `@supabase/config/effect`) loads project env in this -order: +`loadCliProjectEnvironment()` (exported from `@supabase/config/effect`) is the package's public env +loader. The CLI does not call it; it uses its own loader, described under "CLI value precedence". +The public loader loads project env in this order: 1. `supabase/.env` 2. `supabase/.env.local` @@ -329,11 +331,12 @@ The CLI builds runtime state in two layers: `CliProjectContext` is the CLI's discovered-project runtime bundle. It contains: - `paths`: the discovered `CliProjectPaths`, when a project was found -- `projectEnv`: the merged `CliProjectEnvironment`, when a project was found +- `projectEnv`: `{ values }`, the project env files merged into one map (shell values excluded), + when a project was found -It is built by calling `loadCliProjectEnvironment` for the nearest discovered project from `cwd`. -If no `supabase/config.*` exists, both fields stay absent — `CliProjectContext` does not invent a -project from `.supabase/` alone. +It is built by `loadCliProjectEnvFiles` (see "CLI value precedence") for the nearest discovered +project from `cwd`. If no `supabase/config.*` exists, both fields stay absent — `CliProjectContext` +does not invent a project from `.supabase/` alone. ### `CliSettings` @@ -349,11 +352,106 @@ project from `.supabase/` alone. Its values are derived from: - `CliProjectContext.projectEnv.values`, when a project exists -- otherwise `process.env` +- then the ambient shell environment + +`projectEnv.values` never contains a key the shell sets, so the shell still wins. This allows project-scoped env files to influence CLI behavior while keeping CLI runtime settings distinct from the `CliConfig` document. +## CLI value precedence + +The CLI resolves every config value, whether from `CliConfig` or from a `SUPABASE_*` variable, with +one order (ADR 0031): + +1. an explicit flag +2. shell environment +3. project `.env*` files +4. config: `config.json` when present, else `config.toml`, with the matched `[remotes.*]` block + over the base document +5. the default + +Tiers are named `flag`, `shell`, `projectEnv`, `config` and `default`. `pickCliConfigKey` +(`apps/cli/src/config/cli-config-key.ts`) is the single implementation; commands read through the +`CliConfigValues` service rather than calling it. + +### `env()` references and `SUPABASE_*` overrides + +These are different mechanisms and both apply: + +- `env(NAME)` inside a config value is substituted from the merged environment (shell over project + files) when the document is decoded. An empty variable leaves the literal untouched. +- A `SUPABASE_` variable overrides the key at the env tier. An empty variable is + ignored and falls through to config. A value of the form `env(NAME)` in the variable expands once. + A shell variable, even an empty one, shadows the same name in a project `.env` file. + +An override is parsed with a codec derived from the schema type, so an unparsable value fails the +command that loads config instead of falling back to the file. + +### `envRequiresSection` + +An env override for a key inside an optional section applies only when that section exists in the +merged document: webhooks, `storage.image_transformation`, `db.ssl_enforcement`, `auth.captcha`, +`auth.email.smtp`, hooks, SMS providers, passkey, WebAuthn and external providers other than +Apple. `auth.sessions`, `db.settings` and `experimental.pgdelta` are exempt; `pgdelta.enabled` +defaults to true, so its env rollback applies while the section is absent. + +### Remotes + +`[remotes.]` is selected when its effective `project_id` equals the target project ref. The +effective value is `SUPABASE_REMOTES__PROJECT_ID` when set and non-empty, else the +TOML value. Duplicate effective ids and invalid ids fail every load, whether or not a remote +matches. A matched block that does not declare `db.seed.enabled` seeds nothing; a flag or env value +still wins. + +### Project env files + +The CLI's own loader (`shared/config/cli-config-env.ts`) reads `SUPABASE_ENV` (default +`development`) and then, in each of `/supabase` and ``, `.env..local`, +`.env.local` (skipped when the environment is `test`), `.env.` and `.env`. The first writer +of a key wins, and a key the shell sets is never taken from a file. This differs from the public +`loadCliProjectEnvironment` described above. + +### The resolved config + +`CliConfigValues.load({ workdir, projectRef })` returns a resolved config memoised per workdir, project +ref and flag set; `writeThrough` drops the memo around a write. The resolved config exposes: + +- `get(key)`: the value and its origin tier. +- `loaded`: the document with every flag, env and secret winner written in, before defaults. +- `fileDeclared`: what the config file alone declares, with `env()` resolved and no flag or + `SUPABASE_*` overlay. `config pull` compares against it. +- `materialized`: `loaded` plus defaults and normalizers, with `originAt(path)`. +- `origins`, `appliedRemote`, `hasConfigFile`, `declares(path)`, `familyNames(family)`, + `withheldEnv`, `dotenvPrivateKeys`. +- `invalid`: values left out under the `tolerateInvalid` load option; empty otherwise. +- `envValues(names)`: the non-empty value of each name, shell before project `.env*`, for + resolving `env(NAME)` references. +- `projectEnvValues`: the raw project `.env*` record, for variables outside the registry such as + Docker, registry mirrors and `SUPABASE_NETWORK_ID`. + +Load-time warnings print once per runtime. Outside the foundation files the CLI never calls +`loadCliConfig`, `resolveCliConfigSubtree` or `loadCliProjectEnvironment`; a guard test enforces it. + +### Local SMTP + +`auth.email.smtp.enabled` defaults to off in the schema. The CLI treats a present +`[auth.email.smtp]` table that omits `enabled` as on, so a partial table, for example one holding +only `pass = "env(SMTP_PASS)"`, still loads. + +### Pipeline stages from `./internal` + +`@supabase/config/internal` exposes the stages the CLI composes in place of `loadCliConfig`: + +1. `parseCliConfigDocumentFile` reads `config.json` or `config.toml` without decoding. +2. `mergeParsedCliConfig` applies the remote chosen by the `selectRemote` callback. +3. The CLI overlays flag, env and default values onto the merged document. +4. `decodeMergedCliConfig` expands `env()` references, strips deprecated external providers, then decodes and + validates the overlaid document. + +The result is the `materialized` config on the resolved config; `get(key)` also reports the tier a value +came from. + ## CLI-owned Repo State The CLI also keeps machine-local project state outside `@supabase/config`'s scope, in two places diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index c0f68f0c96..354fc244ec 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -494,9 +494,12 @@ describe("src/internal.ts export surface", () => { "ENV_CAPTURE_REGEX", "applyConfigEdits", "decodeCliConfigDocumentForValidationEffect", + "decodeMergedCliConfig", "dualScopeProjectConfigPaths", "loadCliConfig", + "mergeParsedCliConfig", "normalizeDeprecatedOrioleDBVersion", + "parseCliConfigDocumentFile", "projectConfigApiBlockKeys", "projectConfigMappingRows", "remoteNameForProjectRef", diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index a38f7cbc1a..85d9b0b3d3 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -25,7 +25,14 @@ export { type ProjectConfigApiAttributes } from "./project-config/api-attributes export { type InternalLoadCliConfigOptions } from "./config-document.ts"; export { resolveCliConfigValue, resolveCliConfigSubtree } from "./project.ts"; export { + decodeMergedCliConfig, + type DecodeMergedCliConfigOptions, loadCliConfig, + mergeParsedCliConfig, + type MergedCliConfigDocument, + parseCliConfigDocumentFile, + type ParsedCliConfigDocument, + type ParseMergeCliConfigOptions, remoteNameForProjectRef, remoteProjectIdEntries, writeCliConfigDocumentText, diff --git a/packages/config/src/io.stages.unit.test.ts b/packages/config/src/io.stages.unit.test.ts new file mode 100644 index 0000000000..773d2a921d --- /dev/null +++ b/packages/config/src/io.stages.unit.test.ts @@ -0,0 +1,251 @@ +import { BunServices } from "@effect/platform-bun"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { mkdtempSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + decodeMergedCliConfig, + loadCliConfig, + mergeParsedCliConfig, + parseCliConfigDocumentFile, + type ParseMergeCliConfigOptions, +} from "./io.ts"; + +const roots: Array = []; + +const parseMerge = ( + cwd: string, + options: ParseMergeCliConfigOptions & { readonly search?: boolean; readonly tomlOnly?: boolean }, +) => + Effect.gen(function* () { + const parsed = yield* parseCliConfigDocumentFile(cwd, options); + return parsed === null ? null : yield* mergeParsedCliConfig(parsed, options); + }); + +async function makeProject(toml: string): Promise { + const root = mkdtempSync(join(tmpdir(), "supabase-config-stages-")); + roots.push(root); + await mkdir(join(root, "supabase"), { recursive: true }); + await writeFile(join(root, "supabase", "config.toml"), toml); + return root; +} + +function run(effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +const toml = ` +project_id = "local" + +[db] +port = 54399 + +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.db] +major_version = 15 +`; + +describe("config pipeline stages", () => { + test("parseMerge then decode matches loadCliConfig for the same remote", async () => { + const cwd = await makeProject(toml); + const staged = await run( + Effect.gen(function* () { + const merged = yield* parseMerge(cwd, { + search: false, + selectRemote: () => "staging", + }); + if (merged === null) return null; + return yield* decodeMergedCliConfig(merged, { envValues: {} }); + }), + ); + const loaded = await run( + loadCliConfig(cwd, { + search: false, + cliProjectEnv: { + paths: { + projectRoot: cwd, + supabaseDir: join(cwd, "supabase"), + configPath: join(cwd, "supabase", "config.toml"), + envPath: join(cwd, "supabase", ".env"), + envLocalPath: join(cwd, "supabase", ".env.local"), + }, + values: {}, + loadedPaths: [], + sources: {}, + }, + projectRef: "abcdefghijklmnopqrst", + }), + ); + + expect(staged?.appliedRemote).toBe("staging"); + expect(staged?.config).toEqual(loaded?.config); + expect(staged?.config.db.major_version).toBe(15); + expect(staged?.config.db.seed.enabled).toBe(false); + }); + + test("a selector that matches nothing leaves the base document", async () => { + const cwd = await makeProject(toml); + const merged = await run(parseMerge(cwd, { search: false, selectRemote: () => "missing" })); + + expect(merged?.appliedRemote).toBeUndefined(); + expect(merged?.remoteLeafPaths).toEqual([]); + }); + + test("decode takes the overlaid document instead of the merged one", async () => { + const cwd = await makeProject(toml); + const decoded = await run( + Effect.gen(function* () { + const merged = yield* parseMerge(cwd, { + search: false, + selectRemote: () => undefined, + }); + if (merged === null || merged.document === null || typeof merged.document !== "object") { + return null; + } + return yield* decodeMergedCliConfig(merged, { + envValues: {}, + document: { ...merged.document, db: { port: 54400 } }, + }); + }), + ); + + expect(decoded?.config.db.port).toBe(54400); + }); + + test("env references resolve against the supplied env record", async () => { + const cwd = await makeProject('project_id = "env(PROJECT_NAME)"\n'); + const decoded = await run( + Effect.gen(function* () { + const merged = yield* parseMerge(cwd, { + search: false, + selectRemote: () => undefined, + }); + if (merged === null) return null; + return yield* decodeMergedCliConfig(merged, { envValues: { PROJECT_NAME: "from-env" } }); + }), + ); + + expect(decoded?.config.project_id).toBe("from-env"); + expect(decoded?.valueOrigins).toContainEqual({ + path: ["project_id"], + source: "environment", + envVariables: ["PROJECT_NAME"], + }); + }); + + test("a resolved path stays verbatim while an unlisted sibling still resolves", async () => { + const cwd = await makeProject( + 'project_id = "env(PROJECT_NAME)"\n[auth]\nsite_url = "env(SITE)"\n', + ); + const decoded = await run( + Effect.gen(function* () { + const merged = yield* parseMerge(cwd, { search: false, selectRemote: () => undefined }); + if (merged === null) return null; + return yield* decodeMergedCliConfig(merged, { + envValues: { PROJECT_NAME: "from-env", SITE: "http://resolved.test" }, + resolvedPaths: [["auth", "site_url"]], + }); + }), + ); + + expect(decoded?.config.project_id).toBe("from-env"); + expect(decoded?.config.auth.site_url).toBe("env(SITE)"); + }); + + test("onWarning receives each deprecation warning instead of the real console", async () => { + const cwd = await makeProject( + '[inbucket]\nenabled = true\n[auth.external.slack]\nenabled = true\nclient_id = "a"\nsecret = "b"\n', + ); + const warnings: Array = []; + const onWarning = (message: string) => Effect.sync(() => void warnings.push(message)); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await run( + Effect.gen(function* () { + const parsed = yield* parseCliConfigDocumentFile(cwd, { search: false, onWarning }); + if (parsed === null) return; + const merged = yield* mergeParsedCliConfig(parsed, { selectRemote: () => undefined }); + yield* decodeMergedCliConfig(merged, { envValues: {}, cliCompat: true, onWarning }); + }), + ); + + expect(warnings).toEqual([ + "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", + 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + ]); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); +}); + +describe("remote validation", () => { + const duplicateToml = ` +[remotes.a] +project_id = "abcdefghijklmnopqrst" + +[remotes.b] +project_id = "abcdefghijklmnopqrst" +`; + + test("parseMerge leaves remote validation to the caller unless asked", async () => { + const cwd = await makeProject(duplicateToml); + + const unvalidated = await run( + parseMerge(cwd, { search: false, selectRemote: () => undefined }), + ); + const failure = await run( + Effect.flip( + parseMerge(cwd, { + search: false, + selectRemote: () => undefined, + validateRemotes: true, + }), + ), + ); + + expect(unvalidated?.appliedRemote).toBeUndefined(); + expect(failure._tag).toBe("DuplicateRemoteProjectIdError"); + }); + + test("a malformed remote project_id fails the explicit format check", async () => { + const cwd = await makeProject('[remotes.a]\nproject_id = "short"\n'); + + const failure = await run( + Effect.flip( + parseMerge(cwd, { + search: false, + selectRemote: () => undefined, + validateRemotes: true, + }), + ), + ); + + expect(failure._tag).toBe("InvalidRemoteProjectIdError"); + }); + + test("the parse and merge halves compose to parseMerge", async () => { + const cwd = await makeProject(toml); + + const composed = await run( + Effect.gen(function* () { + const parsed = yield* parseCliConfigDocumentFile(cwd, { search: false }); + if (parsed === null) return null; + return yield* mergeParsedCliConfig(parsed, { selectRemote: () => "staging" }); + }), + ); + const whole = await run(parseMerge(cwd, { search: false, selectRemote: () => "staging" })); + + expect(composed).toEqual(whole); + expect(composed?.appliedRemote).toBe("staging"); + }); +}); diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index e63ce2ee8d..fefdc66a0f 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -87,7 +87,7 @@ function pathKey(path: ReadonlyArray): string { /** * Builds a `project_id -> "[remotes.]"` map across every `[remotes.*]` block, failing on - * the first duplicate. {@link applyRemoteOverride} only invokes this when `cliCompat` is + * the first duplicate. {@link applyRemoteOverride} only invokes this when `validateRemotes` is * set, so it runs even for callers that don't request a specific `projectRef`. A missing * `project_id` reads as `""`, so two remotes that both omit it collide on the empty key. */ @@ -174,7 +174,8 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( rawDocument: Record, interpolatedRemotes: Record | undefined, projectRef: string | undefined, - cliCompat: boolean, + validateRemotes: boolean, + selectRemote?: (remotes: Record) => string | undefined, ) { const remotes = rawDocument["remotes"]; if (!isObject(remotes)) { @@ -184,11 +185,17 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( remoteLeafPaths: [], }; } - if (cliCompat) { + if (validateRemotes) { yield* checkDuplicateRemoteProjectIds(remotes); yield* checkRemoteProjectIdFormat(interpolatedRemotes ?? remotes); } - const name = remoteNameForProjectRef(remotes, projectRef); + const selected = selectRemote?.(remotes); + const name = + selectRemote === undefined + ? remoteNameForProjectRef(remotes, projectRef) + : selected !== undefined && Object.hasOwn(remotes, selected) + ? selected + : undefined; if (name === undefined) { return { document: rawDocument, @@ -564,12 +571,15 @@ export const configTomlPath = Effect.fnUntraced(function* (cwd: string) { return configTomlPathWith(path, project?.projectRoot ?? cwd); }); -export const loadCliConfigFile = Effect.fn("CliConfig.loadFile")(function* ( +/** Writes to the real console so a caller's deferred or buffered `Console` cannot delay or swallow it. */ +const warnOnRealConsole = (message: string): Effect.Effect => + Console.error(message).pipe(Effect.provideService(Console.Console, globalThis.console)); + +const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* ( filePath: string, - options?: InternalLoadCliConfigOptions, + onWarning: (message: string) => Effect.Effect = warnOnRealConsole, ) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const format = filePath.endsWith(".json") ? "json" : "toml"; const content = yield* fs.readFileString(filePath); const document = yield* Effect.try({ @@ -580,81 +590,140 @@ export const loadCliConfigFile = Effect.fn("CliConfig.loadFile")(function* ( normalizeDeprecatedSMTPSections(document); const { document: normalized, deprecatedPaths: deprecatedOrioleDBPaths } = normalizeDeprecatedOrioleDBVersion(smtpNormalized); - // Warn on stderr, writing directly to the real console (bypassing whatever `Console.Console` - // is ambient) so a caller wrapping this in a deferred/buffered console can't delay or - // swallow it. for (const section of deprecatedSections) { const replacement = section.replace(/inbucket$/, "local_smtp"); - yield* Console.error( + yield* onWarning( `WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`, - ).pipe(Effect.provideService(Console.Console, globalThis.console)); + ); } for (const path of deprecatedOrioleDBPaths) { const replacement = path.replace(/experimental\.orioledb_version$/, "db.orioledb_version"); - yield* Console.error(`WARN: ${path} is deprecated. Please use ${replacement} instead.`).pipe( - Effect.provideService(Console.Console, globalThis.console), - ); + yield* onWarning(`WARN: ${path} is deprecated. Please use ${replacement} instead.`); } + return { format, content, document, normalized } as const; +}); - // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env before schema - // decode, since a numeric/boolean field would otherwise crash the strict decoder on a string. - // The config file lives two directories under the project root `loadCliProjectEnvironment` expects. - const projectRoot = path.dirname(path.dirname(filePath)); - const cliProjectEnv = - options?.cliProjectEnv ?? - (yield* loadCliProjectEnvironment({ - cwd: projectRoot, - baseEnv: process.env, - search: options?.search, - })); - const cliCompat = options?.cliCompat ?? false; +/** + * Not covered by semver — exported from `@supabase/config/internal` only. The output of the + * parse + merge stage: the raw document with the selected `[remotes.*]` block merged in, still + * pre-`env()`-interpolation and pre-decode. + */ +export interface MergedCliConfigDocument { + readonly path: string; + readonly format: ConfigFormat; + readonly rawText: string; + readonly schemaRef: string | undefined; + readonly ignoredPaths: ReadonlyArray; + /** The document as parsed and deprecation-normalized, before any `[remotes.*]` merge. */ + readonly rawDocument: Record | undefined; + /** The merged document; `remotes` is stripped when a block matched. */ + readonly document: unknown; + readonly appliedRemote: string | undefined; + readonly remoteLeafPaths: ReadonlyArray>; + /** Present when the producing stage already interpolated the `remotes` table. */ + readonly interpolatedRemotes?: Record; +} + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. + */ +export interface ParseMergeCliConfigOptions { + /** Picks the `[remotes.]` block to merge from the raw `remotes` table. */ + readonly selectRemote: (remotes: Record) => string | undefined; + /** + * Run the duplicate and format checks on every `[remotes.*]` `project_id`, as `loadCliConfig` + * does with `cliCompat`. They read the literal values, so a caller that resolves + * `project_id` another way, such as an env override, leaves this off and validates itself. + */ + readonly validateRemotes?: boolean; +} + +const mergeRemoteForLoad = ( + normalized: unknown, + interpolatedRemotes: Record | undefined, + projectRef: string | undefined, + validateRemotes: boolean, + selectRemote?: (remotes: Record) => string | undefined, +): Effect.Effect< + { + readonly document: unknown; + readonly appliedRemote: string | undefined; + readonly remoteLeafPaths: ReadonlyArray; + }, + DuplicateRemoteProjectIdError | InvalidRemoteProjectIdError +> => + isObject(normalized) + ? applyRemoteOverride( + normalized, + interpolatedRemotes, + projectRef, + validateRemotes, + selectRemote, + ) + : Effect.succeed({ document: normalized, appliedRemote: undefined, remoteLeafPaths: [] }); + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. Stage two of the + * pipeline: interpolates `env()` references against `options.envValues`, strips the deprecated + * external providers, and decodes + validates. `options.document` replaces the merged document, + * so a caller can decode a document it has overlaid with effective values. + */ +export interface DecodeMergedCliConfigOptions { + readonly envValues: Readonly>; + readonly cliCompat?: boolean; + readonly document?: Record; + /** Receives each deprecation warning; defaults to writing it to the real console. */ + readonly onWarning?: (message: string) => Effect.Effect; + /** Paths whose values are already resolved: the decode keeps them verbatim instead of expanding `env()` again. */ + readonly resolvedPaths?: ReadonlyArray>; +} + +export const decodeMergedCliConfig = Effect.fn("CliConfig.decodeMerged")(function* ( + merged: MergedCliConfigDocument, + options: DecodeMergedCliConfigOptions, +) { + const cliCompat = options.cliCompat ?? false; + const onWarning = options.onWarning ?? warnOnRealConsole; + const resolvedPathKeys = new Set((options.resolvedPaths ?? []).map(pathKey)); const interpolateDocument = ( document: unknown, onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void, + skip?: (path: ReadonlyArray) => boolean, ): unknown => - interpolateEnvReferencesAgainstSchema(document, cliProjectEnv?.values ?? {}, CliConfigSchema, { + interpolateEnvReferencesAgainstSchema(document, options.envValues, CliConfigSchema, { cliCompat, onResolvedEnv, + skip, }); - // Interpolated once here purely to give `applyRemoteOverride`'s format check (not its - // match/merge) the resolved `remotes.*.project_id`. - const interpolatedForValidation = interpolateDocument(normalized); - const interpolatedRemotes = - isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) - ? interpolatedForValidation["remotes"] - : undefined; - - // Merge the matching `[remotes.*]` override over the raw, pre-`env()` document (see - // `applyRemoteOverride`). The match/merge always runs; the duplicate-`project_id`/format - // checks only run when `cliCompat` is set. - let documentForDecode: unknown = normalized; - let appliedRemote: string | undefined; - let remoteLeafPaths: Array = []; - if (isObject(normalized)) { - const resolved = yield* applyRemoteOverride( - normalized, - interpolatedRemotes, - options?.projectRef, - cliCompat, - ); - documentForDecode = resolved.document; - appliedRemote = resolved.appliedRemote; - remoteLeafPaths = resolved.remoteLeafPaths; + let interpolatedRemotes = merged.interpolatedRemotes; + if (interpolatedRemotes === undefined) { + const interpolated = interpolateDocument(merged.rawDocument); + interpolatedRemotes = + isObject(interpolated) && isObject(interpolated["remotes"]) + ? interpolated["remotes"] + : undefined; } - // The merge above ran on the raw document, so any `env(...)` reference in the winning + // The merge 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. When no - // remote matched this redundantly recomputes `interpolatedForValidation`'s substitutions, but - // correctness on the match+`env()` path matters more than avoiding that. + // remote matched this redundantly recomputes the substitutions the legacy caller already did, + // but correctness on the match+`env()` path matters more than avoiding that. const resolvedEnvironmentPaths: Array = []; const resolvedEnvironmentNames = new Map>(); - documentForDecode = isObject(documentForDecode) - ? interpolateDocument(documentForDecode, (path, envNames) => { - resolvedEnvironmentPaths.push(Array.from(path)); - resolvedEnvironmentNames.set(pathKey(Array.from(path)), envNames); - }) - : documentForDecode; + const documentToDecode: unknown = isObject(merged.document) + ? (options.document ?? merged.document) + : merged.document; + const documentForDecode = isObject(documentToDecode) + ? interpolateDocument( + documentToDecode, + (path, envNames) => { + resolvedEnvironmentPaths.push(Array.from(path)); + resolvedEnvironmentNames.set(pathKey(Array.from(path)), envNames); + }, + (path) => resolvedPathKeys.has(pathKey(path)), + ) + : documentToDecode; // Strip the deprecated `auth.external.{linkedin,slack}` provider ids from the post-remote-merge // document (see `normalizeDeprecatedExternalProviders`). @@ -663,19 +732,23 @@ export const loadCliConfigFile = Effect.fn("CliConfig.loadFile")(function* ( deprecatedProviders, removedProviders, } = normalizeDeprecatedExternalProviders(documentForDecode); - // Pinned to the real console, same as the `[inbucket]` warning above. if (cliCompat) { for (const ext of deprecatedProviders) { - yield* Console.error( + yield* onWarning( `WARN: disabling deprecated "${ext}" provider. Please use [auth.external.${ext}_oidc] instead`, - ).pipe(Effect.provideService(Console.Console, globalThis.console)); + ); } } - const config = yield* parseCliConfig(normalizedForDecode, format, filePath, appliedRemote); + const config = yield* parseCliConfig( + normalizedForDecode, + merged.format, + merged.path, + merged.appliedRemote, + ); - const localPathKeys = new Set(collectLeafPaths(normalized).map(pathKey)); - const remotePathKeys = new Set(remoteLeafPaths.map(pathKey)); + const localPathKeys = new Set(collectLeafPaths(merged.rawDocument).map(pathKey)); + const remotePathKeys = new Set(merged.remoteLeafPaths.map(pathKey)); const environmentPathKeys = new Set(resolvedEnvironmentPaths.map(pathKey)); const valueOrigins = isObject(normalizedForDecode) ? collectLeafPaths(normalizedForDecode).flatMap((path) => { @@ -697,24 +770,85 @@ export const loadCliConfigFile = Effect.fn("CliConfig.loadFile")(function* ( : []; return { - path: filePath, - format, + path: merged.path, + format: merged.format, config, - schemaRef: getSchemaRef(document), - ignoredPaths: [], - rawText: content, + schemaRef: merged.schemaRef, + ignoredPaths: merged.ignoredPaths, + rawText: merged.rawText, document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, - rawDocument: isObject(normalized) ? normalized : undefined, + rawDocument: merged.rawDocument, interpolatedRemotes, - appliedRemote, + appliedRemote: merged.appliedRemote, removedDeprecatedExternalProviders: removedProviders, valueOrigins, } satisfies LoadedCliConfig; }); -export const loadCliConfig = Effect.fn("CliConfig.load")(function* ( - cwd: string, +export const loadCliConfigFile = Effect.fn("CliConfig.loadFile")(function* ( + filePath: string, options?: InternalLoadCliConfigOptions, +) { + const path = yield* Path.Path; + const { format, content, document, normalized } = yield* readAndNormalizeCliConfigFile(filePath); + + // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env before schema + // decode, since a numeric/boolean field would otherwise crash the strict decoder on a string. + // The config file lives two directories under the project root `loadCliProjectEnvironment` expects. + const projectRoot = path.dirname(path.dirname(filePath)); + const cliProjectEnv = + options?.cliProjectEnv ?? + (yield* loadCliProjectEnvironment({ + cwd: projectRoot, + baseEnv: process.env, + search: options?.search, + })); + const cliCompat = options?.cliCompat ?? false; + const envValues = cliProjectEnv?.values ?? {}; + + // Interpolated once here purely to give `applyRemoteOverride`'s format check (not its + // match/merge) the resolved `remotes.*.project_id`. + const interpolatedForValidation = interpolateEnvReferencesAgainstSchema( + normalized, + envValues, + CliConfigSchema, + { cliCompat }, + ); + const interpolatedRemotes = + isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) + ? interpolatedForValidation["remotes"] + : undefined; + + // Merge the matching `[remotes.*]` override over the raw, pre-`env()` document (see + // `applyRemoteOverride`). The match/merge always runs; the duplicate-`project_id`/format + // checks only run when `cliCompat` is set. + const resolved = yield* mergeRemoteForLoad( + normalized, + interpolatedRemotes, + options?.projectRef, + cliCompat, + ); + + return yield* decodeMergedCliConfig( + { + path: filePath, + format, + rawText: content, + schemaRef: getSchemaRef(document), + ignoredPaths: [], + rawDocument: isObject(normalized) ? normalized : undefined, + document: resolved.document, + appliedRemote: resolved.appliedRemote, + remoteLeafPaths: resolved.remoteLeafPaths, + ...(interpolatedRemotes === undefined ? {} : { interpolatedRemotes }), + }, + { envValues, cliCompat }, + ); +}); + +const locateCliConfigFile = Effect.fnUntraced(function* ( + cwd: string, + options: { readonly search?: boolean; readonly tomlOnly?: boolean } | undefined, ) { const fs = yield* FileSystem.FileSystem; const project = yield* findCliProjectPaths(cwd, { search: options?.search }); @@ -731,21 +865,112 @@ export const loadCliConfig = Effect.fn("CliConfig.load")(function* ( : project.configPath.replace(/config\.json$/, "config.toml"); if (!options?.tomlOnly && (yield* fs.exists(jsonPath))) { - const json = yield* loadCliConfigFile(jsonPath, options); - return { - ...json, + filePath: jsonPath, ignoredPaths: (yield* fs.exists(tomlPath)) ? [tomlPath] : [], - } satisfies LoadedCliConfig; + }; } if (yield* fs.exists(tomlPath)) { - return yield* loadCliConfigFile(tomlPath, options); + return { filePath: tomlPath, ignoredPaths: [] }; } return null; }); +export const loadCliConfig = Effect.fn("CliConfig.load")(function* ( + cwd: string, + options?: InternalLoadCliConfigOptions, +) { + const located = yield* locateCliConfigFile(cwd, options); + + if (located === null) { + return null; + } + + const loaded = yield* loadCliConfigFile(located.filePath, options); + return { ...loaded, ignoredPaths: located.ignoredPaths } satisfies LoadedCliConfig; +}); + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. The output of the parse + * half of stage one: the config file read and deprecation-normalized, before any `[remotes.*]` + * merge. + */ +export interface ParsedCliConfigDocument { + readonly path: string; + readonly format: ConfigFormat; + readonly rawText: string; + readonly schemaRef: string | undefined; + readonly ignoredPaths: ReadonlyArray; + readonly rawDocument: Record | undefined; + readonly normalized: unknown; +} + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. Discovers and parses the + * config file without merging a remote, so a caller can inspect the document before choosing one. + * Returns `null` when no config file exists. + */ +export const parseCliConfigDocumentFile = Effect.fn("CliConfig.parseDocument")(function* ( + cwd: string, + options?: { + readonly search?: boolean; + readonly tomlOnly?: boolean; + readonly onWarning?: (message: string) => Effect.Effect; + }, +) { + const located = yield* locateCliConfigFile(cwd, options); + + if (located === null) { + return null; + } + + const { format, content, document, normalized } = yield* readAndNormalizeCliConfigFile( + located.filePath, + options?.onWarning, + ); + + return { + path: located.filePath, + format, + rawText: content, + schemaRef: getSchemaRef(document), + ignoredPaths: located.ignoredPaths, + rawDocument: isObject(normalized) ? normalized : undefined, + normalized, + } satisfies ParsedCliConfigDocument; +}); + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. The merge half of stage + * one: merges the `[remotes.*]` block chosen by `options.selectRemote` over a parsed document. + */ +export const mergeParsedCliConfig = Effect.fn("CliConfig.mergeParsed")(function* ( + parsed: ParsedCliConfigDocument, + options: Pick, +) { + const resolved = yield* mergeRemoteForLoad( + parsed.normalized, + undefined, + undefined, + options.validateRemotes ?? false, + options.selectRemote, + ); + + return { + path: parsed.path, + format: parsed.format, + rawText: parsed.rawText, + schemaRef: parsed.schemaRef, + ignoredPaths: parsed.ignoredPaths, + rawDocument: parsed.rawDocument, + document: resolved.document, + appliedRemote: resolved.appliedRemote, + remoteLeafPaths: resolved.remoteLeafPaths, + } satisfies MergedCliConfigDocument; +}); + const resolveSaveFormat = Effect.fnUntraced(function* ( cwd: string, format: ConfigFormat | undefined, diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 6100e0bf9e..805a5a235d 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -173,7 +173,7 @@ function substituteEnvLeaf( return { value, resolved: false }; } const envName = match[1]; - const resolved = envName === undefined ? undefined : env[envName]; + const resolved = envName !== undefined && Object.hasOwn(env, envName) ? env[envName] : undefined; // A present-but-empty var (e.g. a dotenv `KEY=` line) preserves the `env(KEY)` literal, same // as an unset key, instead of substituting an empty string. if (envName === undefined || resolved === undefined || resolved === "") { @@ -208,7 +208,12 @@ function walk( onResolvedEnv: | ((path: ReadonlyArray, envNames: ReadonlyArray) => void) | undefined, + skip: ((path: ReadonlyArray) => boolean) | undefined, ): unknown { + if (skip?.(path) === true) { + return document; + } + if (Array.isArray(document)) { // Element-level resolutions are reported once, at the array's own path — // one array literal may draw on several env vars, so the names collect. @@ -225,7 +230,7 @@ function walk( }; const result = document.map((item, index) => { const child = ast === null ? null : descendAst(ast, String(index)); - return walk(item, env, child, cliCompat, [...path, String(index)], onResolvedArrayEnv); + return walk(item, env, child, cliCompat, [...path, String(index)], onResolvedArrayEnv, skip); }); if (envNames.length > 0) { onResolvedEnv?.(path, envNames); @@ -234,12 +239,12 @@ function walk( } if (typeof document === "object" && document !== null) { - 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, cliCompat, [...path, key], onResolvedEnv); - } - return result; + return Object.fromEntries( + Object.entries(document).map(([key, value]) => { + const child = ast === null ? null : descendAst(ast, key); + return [key, walk(value, env, child, cliCompat, [...path, key], onResolvedEnv, skip)]; + }), + ); } if (typeof document === "string") { @@ -293,7 +298,17 @@ export function interpolateEnvReferencesAgainstSchema( * leaves report once at the array path, collecting every element's * variable — one array literal may draw on several). */ readonly onResolvedEnv?: (path: ReadonlyArray, envNames: ReadonlyArray) => void; + /** A leaf or array at a path this returns true for is kept verbatim, already resolved. */ + readonly skip?: (path: ReadonlyArray) => boolean; }, ): unknown { - return walk(document, env, schema.ast, options?.cliCompat ?? false, [], options?.onResolvedEnv); + return walk( + document, + env, + schema.ast, + options?.cliCompat ?? false, + [], + options?.onResolvedEnv, + options?.skip, + ); } diff --git a/packages/config/src/lib/env.unit.test.ts b/packages/config/src/lib/env.unit.test.ts index 3298947324..ea0b752acc 100644 --- a/packages/config/src/lib/env.unit.test.ts +++ b/packages/config/src/lib/env.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { Schema } from "effect"; -import { ENV_PATTERN, env } from "./env.ts"; +import { ENV_PATTERN, env, interpolateEnvReferencesAgainstSchema } from "./env.ts"; describe("env()", () => { test("adds env() pattern to JSON schema", () => { @@ -25,3 +25,25 @@ describe("env()", () => { expect(normalized.pattern).toBe(ENV_PATTERN); }); }); + +describe("interpolateEnvReferencesAgainstSchema", () => { + const schema = Schema.Struct({ project_id: Schema.String }); + + test("keeps a __proto__ table name as an own key", () => { + const document = JSON.parse('{"__proto__":{"subject":"file"},"project_id":"x"}'); + + const result = interpolateEnvReferencesAgainstSchema(document, {}, schema); + + expect(Object.getOwnPropertyDescriptor(result, "__proto__")?.value).toEqual({ + subject: "file", + }); + }); + + test("treats a reference to an inherited property name as unset", () => { + expect( + interpolateEnvReferencesAgainstSchema({ project_id: "env(toString)" }, {}, schema, { + cliCompat: true, + }), + ).toEqual({ project_id: "env(toString)" }); + }); +}); diff --git a/packages/config/src/lib/resolve.ts b/packages/config/src/lib/resolve.ts index e10935fab3..e7ff472494 100644 --- a/packages/config/src/lib/resolve.ts +++ b/packages/config/src/lib/resolve.ts @@ -39,7 +39,7 @@ function interpolateLeafValue( return value; } - const resolved = env[envName]; + const resolved = Object.hasOwn(env, envName) ? env[envName] : undefined; // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but // empty (e.g. a dotenv `KEY=` line), the same gate as `substituteEnvLeaf` in // `./env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. @@ -64,13 +64,9 @@ function interpolateValue( } if (typeof value === "object" && value !== null) { - const result: Record = {}; - - for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env, cliCompat); - } - - return result; + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, interpolateValue(child, env, cliCompat)]), + ); } if (typeof value === "string") { @@ -86,13 +82,12 @@ function redactValue(value: unknown, path: ReadonlyArray, cliCompat: boo } if (typeof value === "object" && value !== null) { - const result: Record = {}; - - for (const [key, child] of Object.entries(value)) { - result[key] = redactValue(child, [...path, key], cliCompat); - } - - return result; + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + redactValue(child, [...path, key], cliCompat), + ]), + ); } if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, cliCompat)) { diff --git a/packages/config/src/lib/resolve.unit.test.ts b/packages/config/src/lib/resolve.unit.test.ts index 5a9eee48d1..2e353042e4 100644 --- a/packages/config/src/lib/resolve.unit.test.ts +++ b/packages/config/src/lib/resolve.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { Redacted } from "effect"; import { resolveCliConfigValue, resolveCliConfigSubtree } from "../index.ts"; +import { resolveCliConfigValueAtPath } from "./resolve.ts"; // Behavioral coverage of the two public sync resolvers, imported from the // public `.` entrypoint (not `./resolve.ts` directly) — this is the exact @@ -48,3 +49,21 @@ describe("resolveCliConfigSubtree", () => { } }); }); + +describe("own-key handling", () => { + test("a __proto__ table name survives resolution as an own key", () => { + const document = JSON.parse('{"__proto__":{"subject":"env(FOO)"}}'); + + const resolved = resolveCliConfigSubtree(document, { values: { FOO: "bar" } }, "auth"); + + expect(Object.getOwnPropertyDescriptor(resolved, "__proto__")?.value).toEqual({ + subject: "bar", + }); + }); + + test("a reference to an inherited property name stays unresolved", () => { + expect(resolveCliConfigValueAtPath("env(toString)", { values: {} }, [], true)).toBe( + "env(toString)", + ); + }); +});