diff --git a/apps/cli/src/command-internal/db-config.read.unit.test.ts b/apps/cli/src/command-internal/db-config.read.unit.test.ts index 00da986f48..6a50261ce5 100644 --- a/apps/cli/src/command-internal/db-config.read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.read.unit.test.ts @@ -2488,7 +2488,7 @@ describe("readDbConfig auth.Enabled validation", () => { it.effect("rejects the removed linkedin provider, naming linkedin_oidc", () => failsWith( ["[auth.external.linkedin]", "enabled = true"], - "[auth.external.linkedin] in supabase/config.toml is no longer supported. Use [auth.external.linkedin_oidc] instead.", + "auth.external.linkedin in supabase/config.toml is no longer supported. Use auth.external.linkedin_oidc instead.", ), ); diff --git a/apps/cli/src/command-internal/resolved-config-context.ts b/apps/cli/src/command-internal/resolved-config-context.ts index bffb5a5ba5..80be9d0106 100644 --- a/apps/cli/src/command-internal/resolved-config-context.ts +++ b/apps/cli/src/command-internal/resolved-config-context.ts @@ -1,4 +1,4 @@ -import type { CliConfig } from "@supabase/config"; +import { CliConfigRemovedKeyError, type CliConfig } from "@supabase/config"; import { Effect, Option, Predicate } from "effect"; import { CliConfigKeys } from "../config/cli-config-keys.ts"; @@ -68,6 +68,10 @@ export const resolvePasskeyWebauthn = Effect.fn("ResolvedConfigContext.passkeyWe return { passkeyEnabled, webauthn }; }); +/** The remediation a config load failure carries, when it has one. */ +export const configLoadFailureSuggestion = (cause: unknown): string | undefined => + cause instanceof CliConfigRemovedKeyError ? cause.suggestion : undefined; + /** The failure text commands report when the resolved config cannot be loaded. */ export const describeConfigLoadFailure = (cause: unknown): string => Predicate.isTagged(cause, "CliConfigParseError") && "cause" in cause diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index fb04ff7656..785df05877 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -26,6 +26,7 @@ import { CLI_VERSION } from "../shared/cli/version.ts"; import { decryptSecretMap } from "../shared/config/vault-decrypt.ts"; import { toEdgeRuntimeSecretsEnv } from "../shared/functions/functions-config.ts"; import { + configLoadFailureSuggestion, describeConfigLoadFailure, loadResolvedConfigContext, resolvePasskeyWebauthn, @@ -75,12 +76,17 @@ import { /** A config error suitable for a stack command's user-facing boundary. */ export class StackConfigError extends Data.TaggedError("StackConfigError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } } +/** The remediation a stack config failure carries, for the command error that reports it. */ +export const stackConfigSuggestion = (error: unknown): string | undefined => + error instanceof StackConfigError ? error.suggestion : undefined; + interface StackStartConfig { /** The artifact pins every creation of this command carries. */ readonly selection: ArtifactSelection; @@ -290,7 +296,11 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( Option.fromNullishOr(opts?.projectRef), ).pipe( Effect.mapError( - (cause) => new StackConfigError({ message: describeConfigLoadFailure(cause) }), + (cause) => + new StackConfigError({ + message: describeConfigLoadFailure(cause), + suggestion: configLoadFailureSuggestion(cause), + }), ), ); const { originAt } = resolvedConfig.materialized; diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 0004c75d90..9c64aa5c12 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -136,25 +136,25 @@ cannot be resolved from either the read or the file — reported in their own ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success, **including** a push with nothing to write (the project already matches, so nothing is asked) | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPushWorkdirError`) — beats target resolution, the config load, and every network call | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`ConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | -| `1` | `HostedMutationDeclinedError` — the consent question was declined (rendered as `context canceled`); nothing was written, though the diff and cost lines were already printed | -| `1` | `HostedMutationConsentRequiredError` — there is something to write, no `--yes`/shell `SUPABASE_YES`, and the run cannot prompt (stdin not a TTY, redirected stdout, or machine output); nothing was written | -| `1` | `--remote` 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` | `config.toml`/`config.json` uses a removed key (`[inbucket]`, `experimental.orioledb_version` unless empty, `[auth.external.linkedin]`, `[auth.external.slack]`, at the top level or in a `[remotes.*]` block), or `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` is set to a non-empty value — the message names every removed key with its replacement; nothing is pushed | -| `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) | -| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | -| `1` | the API's effective project configuration fails to parse | -| `1` | the effective-project-config response carried no block at all (`ConfigPushConfigEmptyError`) — nothing was pushed | -| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success, **including** a push with nothing to write (the project already matches, so nothing is asked) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPushWorkdirError`) — beats target resolution, the config load, and every network call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`ConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | +| `1` | `HostedMutationDeclinedError` — the consent question was declined (rendered as `context canceled`); nothing was written, though the diff and cost lines were already printed | +| `1` | `HostedMutationConsentRequiredError` — there is something to write, no `--yes`/shell `SUPABASE_YES`, and the run cannot prompt (stdin not a TTY, redirected stdout, or machine output); nothing was written | +| `1` | `--remote` 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` | `config.toml`/`config.json` uses a removed key (`inbucket`, `experimental.orioledb_version` unless empty, `auth.external.linkedin`, `auth.external.slack`, at the top level or in a `remotes.*` block), or `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` is set to a non-empty value — the message names every removed key with its replacement; nothing is pushed | +| `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) | +| `1` | effective-project-config read failure (network, decode, or unexpected status) — one failure mode now covers what used to be six independent per-service read failures | +| `1` | the API's effective project configuration fails to parse | +| `1` | the effective-project-config response carried no block at all (`ConfigPushConfigEmptyError`) — nothing was pushed | +| `1` | any per-service update failure or webhook-enable failure (network or unexpected status) | ## Output 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 5d2f7b9a35..59d23eabfb 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -2150,7 +2150,7 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toBe( - "[auth.external.slack] in supabase/config.toml is no longer supported. Use [auth.external.slack_oidc] instead.", + "auth.external.slack in supabase/config.toml is no longer supported. Use auth.external.slack_oidc instead.", ); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); 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 fd0f922ca8..0f6236f1cd 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1950,7 +1950,7 @@ describe("db start", () => { const message = Cause.pretty(exit.cause); expect(message).toContain("DbConfigLoadError"); expect(message).toContain( - "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", + "inbucket in supabase/config.toml is no longer supported. Use local_smtp instead.", ); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); diff --git a/apps/cli/src/commands/experimental/stack/install/install.handler.ts b/apps/cli/src/commands/experimental/stack/install/install.handler.ts index 160e091a7f..31ecb2a201 100644 --- a/apps/cli/src/commands/experimental/stack/install/install.handler.ts +++ b/apps/cli/src/commands/experimental/stack/install/install.handler.ts @@ -3,7 +3,10 @@ import type { PreparedRelease } from "@supabase/stack/internal/artifacts"; import { Effect, Path, Ref, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; -import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; +import { + loadStackConfig, + stackConfigSuggestion, +} from "../../../../command-internal/stack-config.ts"; import { LockResolutionError, lockedResolution, @@ -103,6 +106,7 @@ export const stackInstall = Effect.fn("experimental.stack.install")(function* ( : new StackCommandInstallError({ reason: Schema.is(LockResolutionError)(error) ? "lock" : "invalid-config", message: error.message, + suggestion: stackConfigSuggestion(error), cause: error, }), ), diff --git a/apps/cli/src/commands/experimental/stack/outdated/outdated.handler.ts b/apps/cli/src/commands/experimental/stack/outdated/outdated.handler.ts index 7bfacc3f3d..b34eded722 100644 --- a/apps/cli/src/commands/experimental/stack/outdated/outdated.handler.ts +++ b/apps/cli/src/commands/experimental/stack/outdated/outdated.handler.ts @@ -1,7 +1,10 @@ import { defaultArtifactSelection } from "@supabase/stack/defaults"; import { Effect, Ref, Schema } from "effect"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; -import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; +import { + loadStackConfig, + stackConfigSuggestion, +} from "../../../../command-internal/stack-config.ts"; import { LockResolutionError, outdatedServices, @@ -79,6 +82,7 @@ export const stackOutdated = Effect.fn("experimental.stack.outdated")(function* new StackCommandOutdatedError({ reason: Schema.is(LockResolutionError)(error) ? "lock" : "invalid-config", message: error.message, + suggestion: stackConfigSuggestion(error), cause: error, }), ), 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 8e9c81b2f6..476c906a08 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -8,7 +8,10 @@ It creates or resumes the stack for the project and optional `--stack` name, or For a new stack or a stopped existing stack, the CLI loads the target project's `supabase/config.toml`, supported environment overrides, and project dotenv files. It validates the -supported configuration before creating service definitions. When every member of the existing +supported configuration before creating service definitions. A config that uses a removed key +(for example `inbucket`) fails with exit 1 before any stack is created; the error names the key and +its replacement and carries the suggestion `Update supabase/config.toml, then rerun the command.`, +in text and JSON output. When every member of the existing composition is running and healthy, or armed to wake and not already starting, start reports its current endpoints and returns without reading or applying project configuration. When the database is running and every other member is running with any health, starting, or armed to wake, start 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 0e628095c1..2431eed60a 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -71,6 +71,7 @@ import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; import type { CliConfigMaterialized } from "../../../../config/cli-config-values.service.ts"; import { loadStackConfig, + stackConfigSuggestion, stackEndpointKey, stackMajorVersionKey, stackUploadsDir, @@ -245,6 +246,7 @@ const loadStartConfig = Effect.fn("experimental.stack.start.loadConfig")( ? { reason: "lock" as const } : { reason: "invalid-config" as const, configIssue: "project-config" as const }), message: error.message, + suggestion: stackConfigSuggestion(error), cause: error, }), ), 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 4a73319d07..2e176edd57 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 @@ -42,6 +42,7 @@ import { writeLock, } from "@supabase/stack/effect"; import { planSupabaseComposition } from "@supabase/stack/internal/composition"; +import { normalizeCliError } from "../../../../shared/output/normalize-error.ts"; import { mockCommandSettings, mockTelemetryStateTracked, @@ -788,6 +789,29 @@ describe("experimental stack start", () => { }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), ); + it.effect("fails a removed config key with its replacement as the suggestion", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-removed-key-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "removed-key"\n\n[inbucket]\nenabled = true\n', + ); + const fixture = fakeStack(); + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + expect(normalizeCliError(error)).toMatchObject({ + code: "ExperimentalStackStartError", + message: "inbucket in supabase/config.toml is no longer supported. Use local_smtp instead.", + suggestion: "Update supabase/config.toml, then rerun the command.", + }); + expect(fixture.composed).toBe(0); + }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), + ); + it.effect("refuses an unreadable Auth email template before creating a stack", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/commands/experimental/stack/update/update.handler.ts b/apps/cli/src/commands/experimental/stack/update/update.handler.ts index a98737607f..5997da5ec7 100644 --- a/apps/cli/src/commands/experimental/stack/update/update.handler.ts +++ b/apps/cli/src/commands/experimental/stack/update/update.handler.ts @@ -5,7 +5,10 @@ import { CommandPlatformApiFactory } from "../../../../auth/command-platform-api import { probeLinkedTarget } from "../../../../command-internal/hosted-versions.ts"; import { readProjectLink } from "../../../../shared/config/project-link.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; -import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; +import { + loadStackConfig, + stackConfigSuggestion, +} from "../../../../command-internal/stack-config.ts"; import { LOCK_FILE, LockResolutionError, @@ -108,6 +111,7 @@ export const stackUpdate = Effect.fn("experimental.stack.update")(function* ( : new StackCommandUpdateError({ reason: Schema.is(LockResolutionError)(error) ? "lock" : "invalid-config", message: error.message, + suggestion: stackConfigSuggestion(error), cause: error, }), ), 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 0c2bea8032..a006ecd7b8 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -1143,7 +1143,7 @@ FROM_CONFIG = "config-value" Effect.catchTag("CliConfigRemovedKeyError", (error) => Effect.succeed(error.message)), ); expect(message).toBe( - "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", + "inbucket in supabase/config.toml is no longer supported. Use local_smtp instead.", ); expect(api.requests).toHaveLength(0); expect(out.stdoutText).not.toContain("Finished supabase secrets set."); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 6d142b0ffd..2dca504dc4 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -321,6 +321,7 @@ code is surfaced on failure. | `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` | a stack-only flag (`--runtime`, `--stack`, `--stack-id`, `--preparation`, `--frozen`, `--eager`) is given while the legacy backend is active — fails during flag parsing with `Unrecognized flag: ` plus a `Hint:` naming the opt-in (`SUPABASE_EXPERIMENTAL_STACK=1`, or `stack = true` under `[experimental]` with that variable unset); other unrecognized flags get no hint | | `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` | `config.toml` uses a removed key (e.g. `inbucket`) — the message names the key and its replacement and the suggestion is `Update supabase/config.toml, then rerun the command.`, in text and JSON output; fails before any Docker work | | `1` | an enabled function has `import_map.json` but no `deno.json`/`deno.jsonc` and no `functions..import_map` — `import_map.json in a function directory is no longer supported: `; checked in the preflight whenever Edge Runtime or Studio is enabled, so it fails before any container is created | | `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 | diff --git a/apps/cli/src/commands/start/start.errors.ts b/apps/cli/src/commands/start/start.errors.ts index 7b9a1f9cc3..8e15f97591 100644 --- a/apps/cli/src/commands/start/start.errors.ts +++ b/apps/cli/src/commands/start/start.errors.ts @@ -25,6 +25,7 @@ export class StartWorkdirError extends Data.TaggedError("StartWorkdirError")<{ /** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ export class StartConfigLoadError extends Data.TaggedError("StartConfigLoadError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index c426cde812..622c0ef8a6 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -91,6 +91,7 @@ import { type ResolvedAuthEmail, } from "../../command-internal/local-config-values.ts"; import { + configLoadFailureSuggestion, describeConfigLoadFailure, loadLocalResolvedConfigContext, type LocalResolvedConfigContext, @@ -209,7 +210,10 @@ const startConfigFailure = (cause: unknown) => ? new StartInvalidConfigError({ message: `invalid config for ${cause.path}: ${cause.message}`, }) - : new StartConfigLoadError({ message: describeConfigLoadFailure(cause) }); + : new StartConfigLoadError({ + message: describeConfigLoadFailure(cause), + suggestion: configLoadFailureSuggestion(cause), + }); /** * Wraps a synchronous config resolver that throws on a malformed value into a typed diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 1f9fde2252..37d022d158 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -1627,6 +1627,27 @@ describe("start integration", () => { }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), ); + it.effect("fails a removed config key with its replacement as the suggestion", () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configContents: 'project_id = "demo"\n\n[inbucket]\nenabled = true\n', + }); + + const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(normalizeCliError(error)).toMatchObject({ + code: "StartConfigLoadError", + message: + "inbucket in supabase/config.toml is no longer supported. Use local_smtp instead.", + suggestion: "Update supabase/config.toml, then rerun the command.", + }); + } + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])), + ); + it.effect("reports a config.json and config.jsonc conflict without an error-class prefix", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/config/cli-config-failure-key.ts b/apps/cli/src/config/cli-config-failure-key.ts index 97b169e43f..7bbf903467 100644 --- a/apps/cli/src/config/cli-config-failure-key.ts +++ b/apps/cli/src/config/cli-config-failure-key.ts @@ -38,13 +38,11 @@ const isRemovedKeyError = taggedAs("CliConfigRemovedKe const isParseError = taggedAs("CliConfigParseError"); const isValueError = taggedAs("CliConfigValueError"); -const removedKeyPath = (displayKey: string): string | undefined => { - const path = displayKey.replaceAll(/[[\]]/g, ""); - return REMOVED_KEY_PATHS.find( +const removedKeyPath = (path: string): string | undefined => + REMOVED_KEY_PATHS.find( (canonical) => path === canonical || (path.startsWith("remotes.") && path.endsWith(`.${canonical}`)), ); -}; const knownPrefix = (segments: ReadonlyArray): string | undefined => { for (let length = segments.length; length > 0; length--) { diff --git a/apps/cli/src/config/cli-config-failure-key.unit.test.ts b/apps/cli/src/config/cli-config-failure-key.unit.test.ts index 0c916099e1..69c5390f0a 100644 --- a/apps/cli/src/config/cli-config-failure-key.unit.test.ts +++ b/apps/cli/src/config/cli-config-failure-key.unit.test.ts @@ -95,11 +95,11 @@ describe("cliConfigFailureKey for a removed key", () => { new CliConfigRemovedKeyError({ path: "supabase/config.toml", format: "toml", - removedKeys: keys.map((key) => ({ key, replacement: "[replacement]" })), + removedKeys: keys.map((key) => ({ key, replacement: "replacement" })), }); - expect(cliConfigFailureKey(removedKeys(`[${SECRET}]`, "[inbucket]"))).toBe("inbucket"); - expect(cliConfigFailureKey(removedKeys(`[${SECRET}]`))).toBeUndefined(); + expect(cliConfigFailureKey(removedKeys(SECRET, "inbucket"))).toBe("inbucket"); + expect(cliConfigFailureKey(removedKeys(SECRET))).toBeUndefined(); }); }); diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index c33fc68407..b759671b59 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -2251,8 +2251,8 @@ enabled = false expect(failure).toMatchObject({ _tag: "CliConfigRemovedKeyError", removedKeys: [ - { key: "[inbucket]", replacement: "[local_smtp]" }, - { key: "[auth.external.slack]", replacement: "[auth.external.slack_oidc]" }, + { key: "inbucket", replacement: "local_smtp" }, + { key: "auth.external.slack", replacement: "auth.external.slack_oidc" }, ], }); expect(loaded.materialized.config.local_smtp.enabled).toBe(true); diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index 1c02cbbe9f..d59c297e27 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -35,6 +35,12 @@ export class CliConfigRemovedKeyError extends Data.TaggedError("CliConfigRemoved ...this.removedKeys.map(({ key, replacement }) => ` ${key}: use ${replacement} instead.`), ].join("\n"); } + + /** The edit that clears this error, for commands that print a remediation next to the message. */ + get suggestion(): string { + const file = this.path.split(/[\\/]/).slice(-2).join("/"); + return `Update ${file}, then rerun the command.`; + } } /** Both `config.json` and `config.jsonc` exist in one project; `paths` lists every conflicting file. */ diff --git a/packages/config/src/io.stages.unit.test.ts b/packages/config/src/io.stages.unit.test.ts index 1cc0d90c4a..c1e214cf93 100644 --- a/packages/config/src/io.stages.unit.test.ts +++ b/packages/config/src/io.stages.unit.test.ts @@ -172,7 +172,7 @@ enabled = true expect(failure).toMatchObject({ _tag: "CliConfigRemovedKeyError", removedKeys: [ - { key: "[remotes.staging.inbucket]", replacement: "[remotes.staging.local_smtp]" }, + { key: "remotes.staging.inbucket", replacement: "remotes.staging.local_smtp" }, ], }); }), diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index e35c7c648c..a5d09d5f2e 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -2602,14 +2602,14 @@ project_id = "stagingrefaaaaaaaaaa" { name: "[inbucket] whatever its contents", toml: "[inbucket]\nenabled = false\n", - key: "[inbucket]", - replacement: "[local_smtp]", + key: "inbucket", + replacement: "local_smtp", }, { name: "[remotes.*.inbucket]", toml: `${stagingRemote}\n[remotes.staging.inbucket]\nport = 33333\n`, - key: "[remotes.staging.inbucket]", - replacement: "[remotes.staging.local_smtp]", + key: "remotes.staging.inbucket", + replacement: "remotes.staging.local_smtp", }, { name: "a non-empty experimental.orioledb_version", @@ -2632,20 +2632,20 @@ project_id = "stagingrefaaaaaaaaaa" { name: "an enabled [auth.external.slack]", toml: "[auth.external.slack]\nenabled = true\n", - key: "[auth.external.slack]", - replacement: "[auth.external.slack_oidc]", + key: "auth.external.slack", + replacement: "auth.external.slack_oidc", }, { name: "a disabled [auth.external.linkedin]", toml: "[auth.external.linkedin]\nenabled = false\n", - key: "[auth.external.linkedin]", - replacement: "[auth.external.linkedin_oidc]", + key: "auth.external.linkedin", + replacement: "auth.external.linkedin_oidc", }, { name: "a remote's [auth.external.linkedin]", toml: `${stagingRemote}\n[remotes.staging.auth.external.linkedin]\nenabled = true\n`, - key: "[remotes.staging.auth.external.linkedin]", - replacement: "[remotes.staging.auth.external.linkedin_oidc]", + key: "remotes.staging.auth.external.linkedin", + replacement: "remotes.staging.auth.external.linkedin_oidc", }, ])("rejects $name", ({ toml, key, replacement }) => runConfigEffect( @@ -2665,8 +2665,11 @@ project_id = "stagingrefaaaaaaaaaa" ); expect(error.message).toBe( - "[inbucket] in supabase/config.toml is no longer supported. Use [local_smtp] instead.", + "inbucket in supabase/config.toml is no longer supported. Use local_smtp instead.", ); + expect(error).toMatchObject({ + suggestion: "Update supabase/config.toml, then rerun the command.", + }); }), )); @@ -2697,13 +2700,16 @@ enabled = false expect(error.message).toBe( [ "supabase/config.toml uses config keys that are no longer supported:", - " [inbucket]: use [local_smtp] instead.", + " inbucket: use local_smtp instead.", " experimental.orioledb_version: use db.orioledb_version instead.", - " [auth.external.slack]: use [auth.external.slack_oidc] instead.", - " [remotes.staging.inbucket]: use [remotes.staging.local_smtp] instead.", - " [remotes.staging.auth.external.linkedin]: use [remotes.staging.auth.external.linkedin_oidc] instead.", + " auth.external.slack: use auth.external.slack_oidc instead.", + " remotes.staging.inbucket: use remotes.staging.local_smtp instead.", + " remotes.staging.auth.external.linkedin: use remotes.staging.auth.external.linkedin_oidc instead.", ].join("\n"), ); + expect(error).toMatchObject({ + suggestion: "Update supabase/config.toml, then rerun the command.", + }); }), )); @@ -3400,7 +3406,7 @@ describe("decodeCliConfigDocumentForValidationEffect", () => { expect(error).toMatchObject({ _tag: "CliConfigRemovedKeyError", removedKeys: [ - { key: "[inbucket]", replacement: "[local_smtp]" }, + { key: "inbucket", replacement: "local_smtp" }, { key: "remotes.staging.experimental.orioledb_version", replacement: "remotes.staging.db.orioledb_version", diff --git a/packages/config/src/removed-config-keys.ts b/packages/config/src/removed-config-keys.ts index 65fa008e79..004f2968ba 100644 --- a/packages/config/src/removed-config-keys.ts +++ b/packages/config/src/removed-config-keys.ts @@ -18,7 +18,7 @@ function scanScope( let stripped = scope; if (Object.hasOwn(scope, "inbucket")) { - removedKeys.push({ key: `[${prefix}inbucket]`, replacement: `[${prefix}local_smtp]` }); + removedKeys.push({ key: `${prefix}inbucket`, replacement: `${prefix}local_smtp` }); } const experimental = scope["experimental"]; @@ -41,8 +41,8 @@ function scanScope( for (const provider of REMOVED_EXTERNAL_PROVIDERS) { if (Object.hasOwn(external, provider)) { removedKeys.push({ - key: `[${prefix}auth.external.${provider}]`, - replacement: `[${prefix}auth.external.${provider}_oidc]`, + key: `${prefix}auth.external.${provider}`, + replacement: `${prefix}auth.external.${provider}_oidc`, }); } }