Skip to content

Latest commit

 

History

History
573 lines (432 loc) · 28.5 KB

cli-config-loading.md

File metadata and controls

573 lines (432 loc) · 28.5 KB

CLI Config Loading

This document explains how the CLI's on-disk config document loading works, across @supabase/config and the CLI.

Vocabulary

  • CliConfig: the persisted config-file document (supabase/config.toml, supabase/config.json, or supabase/config.jsonc) — the full local superset, including local-only sections (studio, ports, edge_runtime, analytics, …) plus [remotes.*] overrides. Owned by @supabase/config.
  • ProjectConfig: the hosted-project subset — the sections a hosted project manages (api, auth, db, realtime, storage, compute, experimental), produced by toProjectConfig from either a CliConfig document or a Management API v2 project-config response (CLI-2230). Sparse by design: it carries only what its source actually said, so it composes with the subtraction core (subtractCliConfig/omitDefaultValues, operand type EffectiveConfig) without fabricating drift from schema defaults. An API-sourced value may speak for fewer fields than the section list implies — realtime maps no fields today, and compute/ experimental have no v2 project-config API counterpart at all — so a comparison consumer should restrict itself to comparableProjectConfigPaths/isComparableProjectConfigPath rather than treating a section's presence in that list as a per-field guarantee. Owned by @supabase/config (packages/config/src/project-config/).
  • CliSettings: the CLI's effective runtime settings bundle (platform apiUrl, dashboardUrl, access token, telemetry flags, supabaseHome, noKeyring, debug). Lives in apps/cli, not this package.
  • CliProjectEnvironment: the merged env map for the active project (supabase/.env + .env.local + ambient process.env).
  • CliProjectPaths: the discovered filesystem paths for the active project.
  • CliProjectContext: apps/cli's runtime bundle of discovered paths + merged env for the active project.
  • CommandSettings: apps/cli's command-level counterpart to CliSettings — the per-invocation settings every command handler resolves (active profile, --workdir, project id, access token). It predates CliSettings; the two are slated to consolidate. Defined at apps/cli/src/config/command-settings.service.ts.

The Cli* prefix is a rule, not a per-name coincidence: it names the local checkout side — what the CLI reads, writes, or resolves about itself on disk. A bare Project* name is reserved for the hosted Supabase project. Value-helpers follow the config family regardless of their inputs, not the shape of whatever they're passed — resolveCliConfigValue is Cli*-named for this reason. See ADR 0020 for the full decision record.

Within CliConfig itself, project_id is overloaded by position: the root-scope project_id is a local identifier that defaults to the working directory name when running supabase init (see packages/config/src/base.ts), while a [remotes.<label>].project_id is the hosted project's ref — the value that binds that remote block to a specific persistent Supabase branch.

The Cli* prefix names the local checkout side — what the CLI reads, writes, or resolves about itself on disk. A bare Project* name is reserved for the hosted Supabase project side. (Deliberate exceptions live in apps/cli: services that describe the hosted project itself or the CLI's link to it — ProjectLinkRemote, ProjectLinkState (both in shared/config), and RemoteTarget (in apps/cli/src/config) — keep the bare Project* root under this same rule.)

The /io facade

@supabase/config (.), @supabase/config/effect, and @supabase/config/io are the package's three published entrypoints (see the package README's "Entrypoints" section for the full contract). ./io is the one whose names diverge from the vocabulary above: it is a Promise-based facade over ./effect's programs, for non-Effect consumers, and its seven exports (verified against packages/config/src/node.ts/bun.ts) are:

  • loadCliConfig
  • saveCliConfig
  • loadCliConfigFile
  • findCliProjectRoot
  • findCliProjectPaths
  • loadCliProjectEnvironment
  • inferFunctionsManifest

Every name here matches its ./effect counterpart one-to-one (only the return type changes, Effect to Promise) — the subpath itself (/io vs /effect) is what conveys Promise-vs-Effect. findCliProjectRootFor/findCliProjectPathsFor/loadCliProjectEnvironmentFor/ loadFunctionsManifest were the pre-CLI-2234 names: the first three carried a For suffix their ./effect counterparts didn't, and the fourth wrapped ./effect's inferFunctionsManifest under an unrelated verb. CLI-2234 renamed all four to match.

Overview

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

Project discovery (findCliProjectPaths/findCliProjectRoot, exported from @supabase/config/effect) starts from the current working directory and, by default, walks upward until it finds the nearest ancestor containing one of:

  • supabase/config.toml
  • supabase/config.json
  • supabase/config.jsonc

The first match wins. A caller that already holds an authoritative, pre-resolved root — for example one derived from an explicit --workdir/SUPABASE_WORKDIR — can pass { search: false } to check only that exact directory, with no ancestor climb.

Within one discovered supabase/ directory, config.json or config.jsonc takes precedence over config.toml when both exist. Discovery never fails on a config.json + config.jsonc pair; loading and saving do.

This produces a CliProjectPaths (the type is exported from @supabase/config):

  • projectRoot: the matched ancestor directory
  • supabaseDir: ${projectRoot}/supabase
  • configPath: the selected config file path
  • envPath: ${supabaseDir}/.env
  • envLocalPath: ${supabaseDir}/.env.local

Only the nearest matched supabase/ directory is used. The loader does not merge config or env files from higher ancestors.

This discovery behavior is intentionally narrower than the CLI's broader project-state discovery (see "CLI-owned Repo State" below). @supabase/config only cares about committed project config and env files under supabase/.

Config Files and Env Files

The project-scoped files are:

  • supabase/config.toml, supabase/config.json, or supabase/config.jsonc
  • supabase/.env
  • supabase/.env.local

Their intended roles are:

  • config.toml, config.json, or config.jsonc: the shared, committed CliConfig document
  • .env: managed or shared project env values
  • .env.local: user-editable local overrides

The CLI can run from any subdirectory inside a monorepo, but once a project is discovered, all config and env loading is scoped to that project's supabase/ directory.

The loader never merges config from multiple ancestor projects. One discovered project root defines the full config/env scope for that invocation.

Config File Selection and Saving

loadCliConfig() and loadCliConfigFile() (both exported from @supabase/config/effect) apply these rules:

  • loadCliConfig(cwd) discovers the project, then:
    • config.json and config.jsonc are both parsed as JSONC (comments and trailing commas allowed); parse errors report the line and column
    • if both supabase/config.json and supabase/config.jsonc exist, loading fails with CliConfigFileConflictError naming both files
    • if a JSON file and supabase/config.toml both exist, JSON wins, and the returned LoadedCliConfig.ignoredPaths reports the shadowed config.toml; the CLI warns that it is ignored
    • if only one config file exists, that file is loaded
    • if no config file exists in the discovered project, loading returns null
  • loadCliConfigFile(path) loads one explicit file path directly instead of discovering or choosing between sibling formats, so its own ignoredPaths is always empty

saveCliConfig() uses these rules:

  • if the discovered project already has config.jsonc, save back to it; otherwise, if it has config.json, save back to that
  • otherwise if it already has config.toml, save back to TOML
  • otherwise default new writes to supabase/config.json
  • if both config.json and config.jsonc exist, saving fails with CliConfigFileConflictError
  • callers can still force a format explicitly via SaveCliConfigOptions.format
  • when the target is an existing JSON/JSONC file, the save applies minimal jsonc-parser edits towards the regenerated document so comments and key order survive, and falls back to the regenerated text if the edited text does not re-parse to exactly that document; new files and format switches always write the regenerated text
  • a TOML save keeps the existing file's first-line #:schema <url> directive
  • after writing, it removes every other config file among config.json, config.jsonc and config.toml, so a project never ends up with more than one after a save

The saved file may also preserve a top-level "$schema" key as editor metadata: when a caller doesn't pass an explicit schemaRef, saving reuses whatever $schema the project's existing config file already had. That key does not participate in runtime config semantics.

Env Loading and Precedence

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
  3. process.env passed in as baseEnv

The resulting precedence is:

  • process.env wins over .env.local
  • .env.local wins over .env
  • .env provides the lowest-priority project values

The loader returns a CliProjectEnvironment object (the type is exported from @supabase/config) containing:

  • paths
  • values: the merged effective env map
  • loadedPaths
  • sources: per-key provenance (.env, .env.local, or ambient)

The ambient source label just means the value came from process.env.

Raw Config Loading

CliConfig is loaded by parsing the TOML/JSON(C) file and decoding it against CliConfigSchema.

Before decoding, most string values matching env(NAME) are substituted automatically using the resolved CliProjectEnvironment — this is required so numeric and boolean fields don't crash the strict decoder when their TOML/JSON value is still a string. Substitution also coerces the resulting string to the field's declared type: a numeric field is parsed as a number, a boolean field accepts 1/t/T/TRUE/true/True and 0/f/F/FALSE/false/False, and a string-array field is split on ,. A missing or empty-string env var leaves the literal env(NAME) untouched rather than substituting an empty value.

A narrow set of fields are exempt from that pre-decode substitution and keep the literal env(NAME) string all the way through decode — currently only the per-function passthrough values at functions.<name>.env.<VAR_NAME>. Those fields are resolved later, on demand, by a caller — see "Lazy env(NAME) Resolution" below.

Schema defaults still provide true runtime defaults, and Effect schema filters still validate cross-field feature contracts such as enabled => required sibling fields. Raw config loading fails when a feature block is structurally invalid, but not just because a field still contains a literal, unresolved env(NAME).

auth.email.enabled and auth.sms.enabled are the preferred spelling of the provider switch; the decoded name stays enable_signup until v4. The loader folds the alias per section and per layer (top level and each [remotes.*] block), before the [remotes.*] merge, and enabled wins when both are set. Every legacy enable_signup present is reported in the load result's deprecatedPaths, e.g. remotes.staging.auth.sms.enable_signup; the package prints nothing, and the CLI prints a WARN: line on stderr. Saving a config writes enabled.

In-memory effective config validation

Callers that start with a loaded config and apply an in-memory override should validate the effective document before consuming it:

const loaded = yield * loadCliConfig(cwd);
const effective = applyCallerOverrides(loaded.config);
const config = yield * validateCliConfig(effective);
consumeConfig(config);

validateCliConfig is exported from @supabase/config/effect. It reads only the value supplied by the caller: it does not reread config files, read project or ambient environment variables, or interpolate env(NAME) references. It returns an Effect with the native Effect Schema error channel, so schema failures remain typed for the caller to handle.

Validation follows the loader's existing remote policy. The base document receives all schema business-rule checks. Each [remotes.*] block still receives structural decoding, defaults, and transformations, while its business-rule checks are disabled until a caller selects and merges that remote into the effective config. An unselected remote may therefore be an incomplete but structurally valid stub; structurally invalid remote values still fail validation.

Lazy env(NAME) Resolution

A caller can also resolve env(NAME) references explicitly, after config is loaded. The package exposes two helpers, under the same names from both . (plain, synchronous) and @supabase/config/effect (Effect-typed; the Effect-typed variant wins when both are in scope via @supabase/config/effect, since explicit named exports take precedence over a star re-export of the same name). Neither has a failure mode: an unresolved env(NAME) reference is preserved verbatim rather than rejected or thrown (see "Lazy env(NAME) Resolution" behavior below).

  • resolveCliConfigValue(value, cliProjectEnv, configPath)
  • resolveCliConfigSubtree(value, cliProjectEnv, pathPrefix)

Resolution only applies to exact whole-string matches of the form:

env(NAME)

It does not interpolate inside larger strings.

These helpers do two things at once:

  1. Substitute any string that is still a literal env(NAME) reference — this only matters for the deferred fields from the previous section, since everything else was already substituted at load time — using cliProjectEnv.values. Like the pre-decode substitution, a missing or empty-string env var leaves the literal untouched rather than failing.
  2. Wrap every schema-secret-marked (x-secret) leaf that resolved to a real value in Redacted<string> — except a leaf that is still an unresolved literal env(NAME) reference, which passes through as a plain string so a caller can see the missing reference.

resolveCliConfigSubtree walks recursively through objects, arrays, and records, so it also resolves and redacts leaves nested inside [remotes.*] blocks.

An optional cliCompat flag switches the env(NAME) matcher from the default, strict SCREAMING_SNAKE_CASE-only pattern to the Supabase CLI's case-agnostic ^env\((.*)\)$ form; only apps/cli sets it. The public resolveCliConfigValue/resolveCliConfigSubtree on ././effect take no options parameter at all (CLI-2234) — cliCompat is internal-only, typed on InternalResolveCliConfigOptions, a package-internal type that is not itself exported. @supabase/config/internal re-exports these same runtime functions re-typed to additionally accept it; apps/cli imports from there instead.

Callers such as functions serve/functions dev, secrets set, and start call these resolvers on the subtrees they actually need (e.g. auth, edge_runtime, functions), so dormant config — like a disabled Twilio block whose auth_token is still env(TWILIO_AUTH_TOKEN) because that variable was never set — never has to resolve at load time, and no caller pays for resolving or redacting a subtree it doesn't use.

Neither resolver ever fails: an unresolved env(NAME) reference is returned as a plain string, not a typed failure.

Secret Handling

Secret sensitivity is derived from schema annotations: fields wrapped in the package's internal secret() helper (e.g. auth.jwt_secret, edge_runtime.secrets.*) carry an x-secret annotation.

Behavior:

  • decoding never wraps a value in Redacted — schema decode only ever produces plain strings, whether that string is a resolved secret or, for the deferred fields above, still a literal env(NAME)
  • CliProjectEnvironment.values stays a plain string map, for substitution and file IO
  • resolveCliConfigValue/resolveCliConfigSubtree are the only place secret-marked values get wrapped in Redacted<string>, and only once they've resolved to a real value

This keeps persisted config and merged env serializable, while still protecting resolved runtime values once a caller pulls them out for use.

Minimal Config Semantics

All CliConfig keys are optional at input time.

Decoding {} produces the full effective config using schema defaults (getDefaultCliConfig(), a memoized decode of {}). Saving strips values that are equal to those same defaults — function config subtracts its own entry-level defaults separately, since the default config's own functions map is empty — so a saved file stays sparse instead of expanding into a fully-populated template.

That gives the config system two properties at once:

  • legacy default compatibility
  • minimal input: a file containing only project_id = "..." is valid CliConfig input
  • stable round-tripping: decoding a saved file and re-encoding it reproduces the same sparse output, since both sides derive from the same schema defaults

@supabase/config also exposes subtractCliConfig()/omitDefaultValues() as a standalone, pure API over this same subtraction, for comparing two effective configs (e.g. a project's local config against its Management-API-reported effective config) without hand-rolling default stripping.

CLI Composition

The CLI builds runtime state in two layers:

  1. CliProjectContext
  2. CliSettings

CliProjectContext

CliProjectContext is the CLI's discovered-project runtime bundle. It contains:

  • paths: the discovered CliProjectPaths, 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 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

CliSettings contains effective CLI runtime settings such as:

  • apiUrl, dashboardUrl, projectHost — the platform endpoints
  • accessToken
  • supabaseHome
  • noKeyring
  • debug and telemetry flags (telemetryDebug, telemetryDisabled, telemetryPosthogHost, telemetryPosthogKey, doNotTrack)

Its values are derived from:

  • CliProjectContext.projectEnv.values, when a project exists
  • 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 0032):

  1. an explicit flag
  2. shell environment
  3. project .env* files
  4. config: config.json or config.jsonc 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_<UPPER_SNAKE_PATH> 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. SUPABASE_AUTH_EMAIL_ENABLED and SUPABASE_AUTH_SMS_ENABLED are the exception: they set enable_signup in their section and beat ..._ENABLE_SIGNUP at the same tier.

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.<name>] is selected when its effective project_id equals the target project ref. The effective value is SUPABASE_REMOTES_<UPPER(NAME)>_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 <workdir>/supabase and <workdir>, .env.<env>.local, .env.local (skipped when the environment is test), .env.<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.

Removed keys

A config that declares a removed key fails to load with CliConfigRemovedKeyError, which names every removed key in the file, [remotes.*] blocks included, with its replacement:

Removed Replacement
[inbucket] [local_smtp]
[auth.external.linkedin], [auth.external.slack] [auth.external.linkedin_oidc], [auth.external.slack_oidc]
experimental.orioledb_version with a value db.orioledb_version

A remote's keys are reported with their remotes.<name>. prefix. An empty experimental.orioledb_version = "", which older supabase init templates wrote, is ignored: it is dropped from rawDocument before decoding, so it never appears as a value, origin or difference. The published JSON Schema accepts only that empty value, so editors agree with the loader. The CLI also rejects a non-empty SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION environment variable; use SUPABASE_DB_ORIOLEDB_VERSION.

Pipeline stages from ./internal

@supabase/config/internal exposes the stages the CLI composes in place of loadCliConfig:

  1. parseCliConfigDocumentFile reads config.json, config.jsonc or config.toml without decoding, and fails with CliConfigRemovedKeyError when the document uses a removed key, or CliConfigFileConflictError when both JSON files exist.
  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, 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 (see apps/cli/docs/supabase-home.md for the full layout):

  • a repo-local .supabase/ directory, sibling to supabase/, holding checkout-specific caches: linked remote project metadata (project.json) and checkout-local service-version overrides (local-versions.json). It does not hold stack identity markers
  • the global SUPABASE_HOME directory, holding managed-stack metadata and runtime state, keyed by the canonical project root, the full Git branch ref (or detached/ordinary-workspace), and the stack name

Neither is part of @supabase/config's input.

Important distinction:

  • @supabase/config discovers committed config only from supabase/config.toml, supabase/config.json or supabase/config.jsonc
  • the CLI may still resolve a project root from the nearest .supabase/project.json when it needs to find local machine state for commands like link, unlink, start, stop, status, stack list, or logs

In other words, .supabase/ broadens the CLI's own project-state discovery, but it does not broaden config loading semantics in @supabase/config.

What Belongs in CliConfig vs CliSettings

CliConfig should contain committed project intent:

  • local stack settings
  • auth, db, studio, storage, and function config
  • shared dev workflow settings that belong to the repo

CliSettings should contain runtime CLI settings that are not part of the committed project contract:

  • access token
  • the platform endpoints
  • supabaseHome and keyring behavior
  • debug and telemetry flags

The important rule is semantic overlap, not storage overlap. A value does not belong in CliConfig just because it can be sourced from env.

For example:

  • CliSettings.apiUrl is the Supabase platform Management API base URL
  • CliConfig.studio.api_url is the local API URL used by Studio

Those are different meanings and should remain separate.

ProjectConfig — the hosted-project subset (CLI-2230) — sits alongside these two: it converges the same committed-intent fields from either a CliConfig document or a Management API response, without the local-only sections (studio, ports, edge_runtime, analytics, [remotes.*], …) that only make sense for a local checkout. See the Vocabulary entry above for its sparse semantics and comparison contract.

Process Env as Input

The system still tracks value provenance for:

  • precedence
  • diagnostics (CliProjectEnvironment.sources)
  • env file writes

But process.env is treated as infrastructure input, not as an application-level service or domain abstraction.

So the public architecture intentionally stays at:

  • CliConfig
  • CliProjectEnvironment
  • CliProjectPaths
  • CliProjectContext
  • CliSettings
  • ProjectConfig